feat(api): migrate HTTP layer to ofetch with token interceptor
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API 路径管理
|
||||
* 统一管理所有接口路径
|
||||
* 原始 Dart: lib/data/services/api/api_path.dart
|
||||
*/
|
||||
export class ApiPath {
|
||||
private constructor() {}
|
||||
|
||||
// API 基础路径
|
||||
private static readonly _baseUrl = "/api";
|
||||
|
||||
// ============ 功能模块分组 ============
|
||||
private static readonly _auth = `${ApiPath._baseUrl}/auth`;
|
||||
private static readonly _verify = `${ApiPath._baseUrl}/verify`;
|
||||
private static readonly _user = `${ApiPath._baseUrl}/user`;
|
||||
private static readonly _payment = `${ApiPath._baseUrl}/payment`;
|
||||
private static readonly _chat = `${ApiPath._baseUrl}/chat`;
|
||||
|
||||
// ============ 认证相关 ============
|
||||
/** 发送验证码 */
|
||||
static readonly sendCode = `${ApiPath._verify}/send`;
|
||||
|
||||
/** 邮箱密码登录 */
|
||||
static readonly emailLogin = `${ApiPath._auth}/login`;
|
||||
|
||||
/** 注册 */
|
||||
static readonly register = `${ApiPath._auth}/register`;
|
||||
|
||||
/** 设备自动登录 */
|
||||
static readonly guestLogin = `${ApiPath._auth}/guest`;
|
||||
|
||||
/** Apple 登录 */
|
||||
static readonly appleLogin = `${ApiPath._auth}/login/apple`;
|
||||
|
||||
/** Google 登录 */
|
||||
static readonly googleLogin = `${ApiPath._auth}/login/google`;
|
||||
|
||||
/** Facebook 登录 */
|
||||
static readonly facebookLogin = `${ApiPath._auth}/login/facebook`;
|
||||
|
||||
/** Facebook ID 登录(v7.0 新增) */
|
||||
static readonly facebookIdLogin = `${ApiPath._auth}/login/facebook/by-id`;
|
||||
|
||||
/** 刷新 Token */
|
||||
static readonly refresh = `${ApiPath._auth}/refresh`;
|
||||
|
||||
/** 退出登录 */
|
||||
static readonly logout = `${ApiPath._auth}/logout`;
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
static readonly getCurrentUser = `${ApiPath._auth}/me`;
|
||||
|
||||
// ============ 用户相关 ============
|
||||
/** 获取用户统计信息 */
|
||||
static readonly userStats = `${ApiPath._user}/stats`;
|
||||
|
||||
/** 获取个人信息(与 /auth/me 等价) */
|
||||
static readonly userProfile = `${ApiPath._user}/profile`;
|
||||
|
||||
/** 更新个人信息 */
|
||||
static readonly updateProfile = `${ApiPath._user}/profile`;
|
||||
|
||||
/** 上传头像 */
|
||||
static readonly userAvatar = `${ApiPath._user}/avatar`;
|
||||
|
||||
/** 查询积分余额 */
|
||||
static readonly userCredits = `${ApiPath._user}/credits`;
|
||||
|
||||
/** 查询积分操作历史 */
|
||||
static readonly userCreditsHistory = `${ApiPath._user}/credits/history`;
|
||||
|
||||
// ============ 支付相关 ============
|
||||
/** 创建充值订单 */
|
||||
static readonly paymentCreateOrder = `${ApiPath._payment}/order/create`;
|
||||
|
||||
/** 查询订单状态 */
|
||||
static readonly paymentOrderStatus = `${ApiPath._payment}/order/status`;
|
||||
|
||||
/** 获取商品套餐列表 */
|
||||
static readonly paymentProducts = `${ApiPath._payment}/products`;
|
||||
|
||||
/** 申请退款 */
|
||||
static readonly paymentRefund = `${ApiPath._payment}/refund`;
|
||||
|
||||
/** 获取商品详情路径 */
|
||||
static getPaymentProductDetail(productId: string): string {
|
||||
return `${ApiPath.paymentProducts}/${productId}`;
|
||||
}
|
||||
|
||||
// ============ 聊天相关 ============
|
||||
/** 发送消息 */
|
||||
static readonly chatSend = `${ApiPath._chat}/send`;
|
||||
|
||||
/** 获取聊天历史 */
|
||||
static readonly chatHistory = `${ApiPath._chat}/history`;
|
||||
|
||||
/** 语音转文字(STT) */
|
||||
static readonly chatStt = `${ApiPath._chat}/stt`;
|
||||
|
||||
/** 同步游客消息 */
|
||||
static readonly chatSync = `${ApiPath._chat}/sync`;
|
||||
|
||||
/** 上传图片 */
|
||||
static readonly chatUploadImage = `${ApiPath._chat}/upload-image`;
|
||||
|
||||
// ============ 数据看板相关 ============
|
||||
private static readonly _metrics = `${ApiPath._baseUrl}/metrics`;
|
||||
|
||||
/** 上报 PWA 事件 */
|
||||
static readonly metricsPwaEvent = `${ApiPath._metrics}/pwa/event`;
|
||||
|
||||
// ============ 数据上报相关(v7.0 新增) ============
|
||||
private static readonly _data = `${ApiPath._baseUrl}/data`;
|
||||
|
||||
/** 上报用户信息 */
|
||||
static readonly reportUserInfo = `${ApiPath._data}/report-user-info`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 统一 API 响应包装
|
||||
*
|
||||
* 与 Dart 的 `NetResult<T>` 对应:
|
||||
* - 成功:`{ success: true, data: T }`
|
||||
* - 失败:`{ success: false, error: ApiError }`
|
||||
*/
|
||||
export type ApiResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: ApiError };
|
||||
|
||||
/**
|
||||
* API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly code: string;
|
||||
readonly status?: number;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
status?: number,
|
||||
details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 JSON 字符串
|
||||
*/
|
||||
override toString(): string {
|
||||
const statusStr = this.status !== undefined ? ` [${this.status}]` : "";
|
||||
return `${this.name}${statusStr} ${this.code}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误码常量
|
||||
*/
|
||||
export class ErrorCode {
|
||||
private constructor() {}
|
||||
|
||||
static readonly httpUnauthorized = 401;
|
||||
static readonly httpForbidden = 403;
|
||||
static readonly httpNotFound = 404;
|
||||
static readonly httpServerError = 500;
|
||||
static readonly httpBadRequest = 400;
|
||||
|
||||
static readonly networkError = "NETWORK_ERROR";
|
||||
static readonly timeoutError = "TIMEOUT_ERROR";
|
||||
static readonly parseError = "PARSE_ERROR";
|
||||
static readonly unknownError = "UNKNOWN_ERROR";
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Auth API
|
||||
*
|
||||
* 认证相关接口
|
||||
* 原始 Dart: lib/data/services/api/auth_api_client.dart
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import {
|
||||
AppleLoginRequest,
|
||||
FacebookLoginRequest,
|
||||
FbIdLoginRequest,
|
||||
GoogleLoginRequest,
|
||||
GuestLoginRequest,
|
||||
GuestLoginResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
RegisterRequest,
|
||||
SendCodeRequest,
|
||||
User,
|
||||
type UserData,
|
||||
} from "@/data/models";
|
||||
|
||||
/**
|
||||
* 后端统一响应包装
|
||||
*/
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>): T {
|
||||
if (!envelope.success || envelope.data === undefined) {
|
||||
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export class AuthApi {
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
async sendCode(body: SendCodeRequest): Promise<void> {
|
||||
await httpClient<ApiEnvelope<unknown>>(ApiPath.sendCode, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱密码登录
|
||||
*/
|
||||
async emailLogin(body: LoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.emailLogin,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
async register(body: RegisterRequest): Promise<void> {
|
||||
await httpClient<ApiEnvelope<unknown>>(ApiPath.register, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple 登录
|
||||
*/
|
||||
async appleLogin(body: AppleLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.appleLogin, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 登录
|
||||
*/
|
||||
async googleLogin(body: GoogleLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.googleLogin, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facebook 登录
|
||||
*/
|
||||
async facebookLogin(body: FacebookLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.facebookLogin,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facebook ID 登录(v7.0 新增)
|
||||
*/
|
||||
async facebookIdLogin(body: FbIdLoginRequest): Promise<LoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.facebookIdLogin,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
return LoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
async logout(): Promise<LogoutResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.logout, {
|
||||
method: "POST",
|
||||
});
|
||||
return LogoutResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 游客登录
|
||||
*/
|
||||
async guestLogin(body: GuestLoginRequest): Promise<GuestLoginResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.guestLogin, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return GuestLoginResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async refreshToken(
|
||||
body: RefreshTokenRequest
|
||||
): Promise<RefreshTokenResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.refresh, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return RefreshTokenResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
async getCurrentUser(): Promise<User> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.getCurrentUser
|
||||
);
|
||||
return User.fromJson(unwrap(env) as UserData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例
|
||||
*/
|
||||
export const authApi = new AuthApi();
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Chat API
|
||||
*
|
||||
* 聊天相关接口
|
||||
* 原始 Dart: lib/data/services/api/chat_api_client.dart
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import {
|
||||
ChatHistoryResponse,
|
||||
ChatSendResponse,
|
||||
ChatSyncData,
|
||||
ChatSyncRequest,
|
||||
ImageUploadResponse,
|
||||
SendMessageRequest,
|
||||
SttData,
|
||||
SyncMessage,
|
||||
} from "@/data/models";
|
||||
|
||||
/**
|
||||
* 后端统一响应包装
|
||||
*/
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>): T {
|
||||
if (!envelope.success || envelope.data === undefined) {
|
||||
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export class ChatApi {
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return ChatSendResponse.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
*/
|
||||
async getHistory(limit = 50, offset = 0): Promise<ChatHistoryResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||
query: { limit, offset },
|
||||
});
|
||||
return ChatHistoryResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转文字(multipart 上传)
|
||||
*/
|
||||
async speechToText(audio: Blob | File): Promise<SttData> {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audio);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatStt, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return SttData.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步游客消息
|
||||
*/
|
||||
async syncMessages(
|
||||
messages: SyncMessage[]
|
||||
): Promise<ChatSyncData> {
|
||||
const body = ChatSyncRequest.from({ messages });
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSync, {
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return ChatSyncData.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片
|
||||
*/
|
||||
async uploadImage(image: Blob | File): Promise<ImageUploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("image", image);
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.chatUploadImage,
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
return ImageUploadResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例
|
||||
*/
|
||||
export const chatApi = new ChatApi();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* API 配置管理
|
||||
*
|
||||
* 通过环境变量配置后端服务地址与超时时间。
|
||||
* 原始 Dart: lib/core/net/service_manager.dart
|
||||
*/
|
||||
|
||||
export type AppEnv = "development" | "test" | "production";
|
||||
|
||||
export interface ApiConfig {
|
||||
/** 后端 API 基础 URL(含协议) */
|
||||
baseUrl: string;
|
||||
/** WebSocket 基础 URL */
|
||||
wsUrl: string;
|
||||
/** 连接超时(ms) */
|
||||
connectTimeout: number;
|
||||
/** 接收超时(ms) */
|
||||
receiveTimeout: number;
|
||||
/** 发送超时(ms) */
|
||||
sendTimeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时常量
|
||||
*/
|
||||
export class TimeoutConstants {
|
||||
private constructor() {}
|
||||
static readonly shortTimeout = 30000;
|
||||
static readonly longTimeout = 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前环境
|
||||
*/
|
||||
export function getAppEnv(): AppEnv {
|
||||
const env = process.env.NEXT_PUBLIC_APP_ENV;
|
||||
if (env === "production" || env === "test") return env;
|
||||
return "development";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 API 配置
|
||||
*/
|
||||
export function getApiConfig(): ApiConfig {
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://172.16.48.49:3002";
|
||||
const wsUrl =
|
||||
process.env.NEXT_PUBLIC_WS_BASE_URL ?? "ws://172.16.48.49:3002";
|
||||
const connectTimeout = Number.parseInt(
|
||||
process.env.NEXT_PUBLIC_API_CONNECT_TIMEOUT ?? "30000",
|
||||
10
|
||||
);
|
||||
const receiveTimeout = Number.parseInt(
|
||||
process.env.NEXT_PUBLIC_API_RECEIVE_TIMEOUT ?? "60000",
|
||||
10
|
||||
);
|
||||
const sendTimeout = Number.parseInt(
|
||||
process.env.NEXT_PUBLIC_API_SEND_TIMEOUT ?? "30000",
|
||||
10
|
||||
);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
wsUrl,
|
||||
connectTimeout,
|
||||
receiveTimeout,
|
||||
sendTimeout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* HTTP 客户端工厂
|
||||
*
|
||||
* 创建配置好拦截器的 ofetch 实例。
|
||||
* 原始 Dart: lib/core/net/backend_service.dart
|
||||
*/
|
||||
import { ofetch, type $Fetch, type FetchHook } from "ofetch";
|
||||
import { ApiError, ErrorCode } from "./api_result";
|
||||
import { getApiConfig } from "./config/api_config";
|
||||
import { authRefreshInterceptor } from "./interceptor/auth_refresh_interceptor";
|
||||
import { loggingInterceptor } from "./interceptor/logging_interceptor";
|
||||
import { tokenInterceptor } from "./interceptor/token_interceptor";
|
||||
|
||||
/**
|
||||
* 合并多个同类型 hook 为单个 hook(按顺序执行)
|
||||
*/
|
||||
function chain(...fns: (FetchHook | undefined)[]): FetchHook {
|
||||
return async (ctx: unknown) => {
|
||||
for (const fn of fns) {
|
||||
if (fn) await (fn as (c: unknown) => Promise<void> | void)(ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ofetch 实例
|
||||
*/
|
||||
export function createHttpClient(): $Fetch {
|
||||
const config = getApiConfig();
|
||||
|
||||
return ofetch.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.receiveTimeout,
|
||||
retry: 0, // 401 刷新由 auth_refresh_interceptor 自定义处理
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
onRequest: chain(
|
||||
loggingInterceptor.onRequest,
|
||||
tokenInterceptor.onRequest
|
||||
),
|
||||
onResponse: chain(loggingInterceptor.onResponse),
|
||||
onResponseError: chain(
|
||||
loggingInterceptor.onResponseError,
|
||||
authRefreshInterceptor.onResponseError,
|
||||
((ctx: unknown) => {
|
||||
const c = ctx as {
|
||||
response?: { status?: number; _data?: unknown };
|
||||
error?: Error;
|
||||
};
|
||||
const status = c.response?.status;
|
||||
const code =
|
||||
status === ErrorCode.httpUnauthorized
|
||||
? "HTTP_UNAUTHORIZED"
|
||||
: status === ErrorCode.httpServerError
|
||||
? "HTTP_SERVER_ERROR"
|
||||
: "HTTP_ERROR";
|
||||
const data = c.response?._data;
|
||||
const message =
|
||||
(data as { message?: string; error?: string } | undefined)
|
||||
?.message ??
|
||||
(data as { message?: string; error?: string } | undefined)
|
||||
?.error ??
|
||||
c.error?.message ??
|
||||
"Unknown error";
|
||||
throw new ApiError(code, message, status, data);
|
||||
}) as FetchHook
|
||||
),
|
||||
onRequestError: chain(loggingInterceptor.onResponseError),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认 HTTP 客户端实例
|
||||
*/
|
||||
export const httpClient = createHttpClient();
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./index";
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 认证刷新拦截器
|
||||
*
|
||||
* 处理 401 错误,自动刷新 token 并重试原请求。
|
||||
* 原始 Dart: lib/core/net/interceptor/auth_interceptor.dart
|
||||
*/
|
||||
import type { FetchHook } from "ofetch";
|
||||
import { ApiPath } from "../api_path";
|
||||
import { ApiError, ErrorCode } from "../api_result";
|
||||
import { authStorage } from "../../storage/auth_storage";
|
||||
import { deviceIdentifier } from "@/utils/device_identifier";
|
||||
|
||||
/**
|
||||
* 不需要 auth token 刷新的路径
|
||||
* (这些路径返回 401 时不触发刷新)
|
||||
*/
|
||||
const NO_AUTH_REFRESH_PATHS: readonly string[] = [
|
||||
ApiPath.sendCode,
|
||||
ApiPath.emailLogin,
|
||||
ApiPath.register,
|
||||
ApiPath.guestLogin,
|
||||
ApiPath.refresh,
|
||||
];
|
||||
|
||||
/**
|
||||
* 是否正在刷新 token(防止并发刷新)
|
||||
*/
|
||||
let isRefreshing = false;
|
||||
|
||||
/**
|
||||
* 等待中的刷新 Promise
|
||||
*/
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* 实际刷新 token(被拦截器调用)
|
||||
*/
|
||||
async function refreshToken(
|
||||
isGuestMode: boolean,
|
||||
baseUrl: string
|
||||
): Promise<void> {
|
||||
if (isGuestMode) {
|
||||
await refreshGuestToken(baseUrl);
|
||||
} else {
|
||||
await refreshLoginToken(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新登录 token
|
||||
*/
|
||||
async function refreshLoginToken(baseUrl: string): Promise<void> {
|
||||
const refreshToken = authStorage.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${ApiPath.refresh}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
success?: boolean;
|
||||
data?: { token?: string; refreshToken?: string };
|
||||
};
|
||||
|
||||
if (json.success && json.data) {
|
||||
if (json.data.token) authStorage.setLoginToken(json.data.token);
|
||||
if (json.data.refreshToken)
|
||||
authStorage.setRefreshToken(json.data.refreshToken);
|
||||
} else {
|
||||
throw new Error("Refresh response invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新游客 token
|
||||
*/
|
||||
async function refreshGuestToken(baseUrl: string): Promise<void> {
|
||||
const deviceId = await deviceIdentifier.getDeviceId();
|
||||
|
||||
const response = await fetch(`${baseUrl}${ApiPath.guestLogin}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deviceId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Guest refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
success?: boolean;
|
||||
data?: { token?: string; deviceId?: string };
|
||||
};
|
||||
|
||||
if (json.success && json.data) {
|
||||
if (json.data.token) authStorage.setGuestToken(json.data.token);
|
||||
if (json.data.deviceId) authStorage.setDeviceId(json.data.deviceId);
|
||||
} else {
|
||||
throw new Error("Guest refresh response invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 401 → 刷新 token 并重试
|
||||
*/
|
||||
export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
||||
const status = ctx.response?.status;
|
||||
|
||||
// 只处理 401
|
||||
if (status !== ErrorCode.httpUnauthorized) return;
|
||||
|
||||
// 跳过不需要刷新的路径
|
||||
const requestUrl =
|
||||
ctx.request instanceof Request ? ctx.request.url : ctx.request;
|
||||
const path = new URL(requestUrl).pathname;
|
||||
if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return;
|
||||
|
||||
// 已登录则刷新登录 token,否则刷新游客 token
|
||||
const isGuestMode = !authStorage.hasLoginToken();
|
||||
|
||||
// 并发刷新:等待或启动
|
||||
if (isRefreshing && refreshPromise) {
|
||||
await refreshPromise;
|
||||
} else {
|
||||
isRefreshing = true;
|
||||
const baseUrl = new URL(requestUrl).origin;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
await refreshToken(isGuestMode, baseUrl);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
await refreshPromise;
|
||||
}
|
||||
|
||||
// 刷新失败 → 清除数据并抛出
|
||||
const newToken = authStorage.getAuthToken();
|
||||
if (!newToken) {
|
||||
authStorage.clearAuthData();
|
||||
throw new ApiError(
|
||||
ErrorCode.unknownError,
|
||||
"Token refresh failed",
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新成功:更新请求头的 token
|
||||
if (ctx.request instanceof Request) {
|
||||
ctx.request.headers.set("Authorization", `Bearer ${newToken}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧接口的 FetchHooks 形式
|
||||
*/
|
||||
export const authRefreshInterceptor = {
|
||||
onResponseError: onResponseErrorAuthRefresh,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 日志拦截器
|
||||
*
|
||||
* 在开发模式下记录请求/响应/错误日志。
|
||||
* 原始 Dart: lib/core/net/interceptor/app_interceptor.dart 中的日志部分。
|
||||
*/
|
||||
import type { FetchHook } from "ofetch";
|
||||
|
||||
const TAG = "[API]";
|
||||
|
||||
function isDev(): boolean {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
function getRequestUrl(request: Request | string): string {
|
||||
return typeof request === "string" ? request : request.url;
|
||||
}
|
||||
|
||||
function getRequestMethod(
|
||||
request: Request | string,
|
||||
options: { method?: string }
|
||||
): string {
|
||||
return request instanceof Request ? request.method : options.method ?? "GET";
|
||||
}
|
||||
|
||||
function formatBody(body: unknown): string {
|
||||
if (body === undefined || body === null) return "";
|
||||
try {
|
||||
if (body instanceof FormData) {
|
||||
const entries: string[] = [];
|
||||
body.forEach((value, key) => {
|
||||
if (value instanceof File) {
|
||||
entries.push(`${key}=File(${value.name})`);
|
||||
} else {
|
||||
entries.push(`${key}=${String(value)}`);
|
||||
}
|
||||
});
|
||||
return `FormData{${entries.join(", ")}}`;
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return String(body);
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestLogging: FetchHook = async (ctx) => {
|
||||
if (!isDev()) return;
|
||||
const method = getRequestMethod(ctx.request, ctx.options);
|
||||
const url = getRequestUrl(ctx.request);
|
||||
console.log(
|
||||
`${TAG} → ${method} ${url}`,
|
||||
ctx.options.body !== undefined ? formatBody(ctx.options.body) : ""
|
||||
);
|
||||
};
|
||||
|
||||
export const onResponseLogging: FetchHook = async (ctx) => {
|
||||
if (!isDev() || !ctx.response) return;
|
||||
const method = getRequestMethod(ctx.request, ctx.options);
|
||||
const url = getRequestUrl(ctx.request);
|
||||
console.log(
|
||||
`${TAG} ← ${ctx.response.status} ${method} ${url}`,
|
||||
formatBody(ctx.response._data)
|
||||
);
|
||||
};
|
||||
|
||||
export const onErrorLogging: FetchHook = async (ctx) => {
|
||||
if (!isDev()) return;
|
||||
const status = ctx.response?.status ?? "N/A";
|
||||
const method = getRequestMethod(ctx.request, ctx.options);
|
||||
const url = getRequestUrl(ctx.request);
|
||||
console.error(
|
||||
`${TAG} ✕ ${status} ${method} ${url}`,
|
||||
ctx.error?.message ?? "Unknown error"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧接口的 FetchHooks 形式(保留以备兼容)
|
||||
*/
|
||||
export const loggingInterceptor = {
|
||||
onRequest: onRequestLogging,
|
||||
onResponse: onResponseLogging,
|
||||
onResponseError: onErrorLogging,
|
||||
onRequestError: onErrorLogging,
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Token 拦截器
|
||||
*
|
||||
* 在请求头中注入 Authorization: Bearer <token>。
|
||||
* 跳过不需要 token 的路径(登录、注册、发送验证码、刷新等)。
|
||||
* 原始 Dart: lib/core/net/interceptor/token_interceptor.dart + token_paths.dart
|
||||
*/
|
||||
import type { FetchHook } from "ofetch";
|
||||
import { ApiPath } from "../api_path";
|
||||
import { authStorage } from "../../storage/auth_storage";
|
||||
|
||||
/**
|
||||
* 不需要 token 的路径
|
||||
*/
|
||||
const NO_TOKEN_PATHS: readonly string[] = [
|
||||
ApiPath.sendCode,
|
||||
ApiPath.emailLogin,
|
||||
ApiPath.register,
|
||||
ApiPath.guestLogin,
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取请求路径
|
||||
*/
|
||||
function getRequestPath(request: Request | string): string {
|
||||
if (typeof request === "string") {
|
||||
try {
|
||||
return new URL(request).pathname;
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
return new URL(request.url).pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径是否需要 token
|
||||
*/
|
||||
function needsToken(path: string): boolean {
|
||||
return !NO_TOKEN_PATHS.some((noTokenPath) => path.includes(noTokenPath));
|
||||
}
|
||||
|
||||
export const onRequestToken: FetchHook = async (ctx) => {
|
||||
const path = getRequestPath(ctx.request);
|
||||
if (!needsToken(path)) return;
|
||||
|
||||
const token = authStorage.getAuthToken();
|
||||
if (token && ctx.request instanceof Request) {
|
||||
ctx.request.headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧接口的 FetchHooks 形式
|
||||
*/
|
||||
export const tokenInterceptor = {
|
||||
onRequest: onRequestToken,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Metrics API
|
||||
*
|
||||
* 数据看板/上报相关接口
|
||||
* 原始 Dart: lib/data/services/api/metrics_api_client.dart
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import { AppEvent, PwaEvent } from "@/data/models";
|
||||
|
||||
/**
|
||||
* 后端统一响应包装
|
||||
*/
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>): T | undefined {
|
||||
if (!envelope.success) {
|
||||
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export class MetricsApi {
|
||||
/**
|
||||
* 上报 PWA 事件
|
||||
*/
|
||||
async reportPwaEvent(body: PwaEvent): Promise<void> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.metricsPwaEvent,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
unwrap(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报用户信息
|
||||
*/
|
||||
async reportUserInfo(body: AppEvent): Promise<void> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.reportUserInfo,
|
||||
{ method: "POST", body: body.toJson() }
|
||||
);
|
||||
unwrap(env);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例
|
||||
*/
|
||||
export const metricsApi = new MetricsApi();
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* User API
|
||||
*
|
||||
* 用户相关接口
|
||||
* 原始 Dart: lib/data/services/api/user_api_client.dart
|
||||
*/
|
||||
import { httpClient } from "./http_client";
|
||||
import { ApiPath } from "./api_path";
|
||||
import {
|
||||
CreditsData,
|
||||
CreditsHistoryData,
|
||||
UpdateProfileRequest,
|
||||
User,
|
||||
UserStatsResponse,
|
||||
type UserData,
|
||||
} from "@/data/models";
|
||||
|
||||
/**
|
||||
* 后端统一响应包装
|
||||
*/
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>): T {
|
||||
if (!envelope.success || envelope.data === undefined) {
|
||||
throw new Error(envelope.message ?? envelope.error ?? "API call failed");
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export class UserApi {
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
*/
|
||||
async getUserStats(): Promise<UserStatsResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userStats);
|
||||
return UserStatsResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取个人信息
|
||||
*/
|
||||
async getCurrentUser(): Promise<User> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userProfile);
|
||||
return User.fromJson(unwrap(env) as UserData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新个人资料
|
||||
*/
|
||||
async updateProfile(body: UpdateProfileRequest): Promise<User> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.updateProfile, {
|
||||
method: "PUT",
|
||||
body: body.toJson(),
|
||||
});
|
||||
return User.fromJson(unwrap(env) as UserData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询积分余额
|
||||
*/
|
||||
async getCredits(): Promise<CreditsData> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userCredits);
|
||||
return CreditsData.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询积分操作历史
|
||||
*/
|
||||
async getCreditsHistory(
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<CreditsHistoryData> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.userCreditsHistory,
|
||||
{ query: { limit, offset } }
|
||||
);
|
||||
return CreditsHistoryData.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例
|
||||
*/
|
||||
export const userApi = new UserApi();
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Auth 存储 - localStorage 包装
|
||||
*
|
||||
* 存储登录 token、刷新 token、游客 token、设备 ID。
|
||||
* 原始 Dart: lib/data/services/storage/auth_storage.dart
|
||||
*
|
||||
* 注:localStorage 仅在浏览器环境可用;
|
||||
* 在 Server Components 中调用会抛错,应仅在 Client Components 或拦截器中使用。
|
||||
*/
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
loginToken: "cozsweet.loginToken",
|
||||
refreshToken: "cozsweet.refreshToken",
|
||||
guestToken: "cozsweet.guestToken",
|
||||
deviceId: "cozsweet.deviceId",
|
||||
} as const;
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthStorage {
|
||||
// ============ 登录 Token ============
|
||||
|
||||
getLoginToken(): string | null {
|
||||
return getStorage()?.getItem(STORAGE_KEYS.loginToken) ?? null;
|
||||
}
|
||||
|
||||
setLoginToken(token: string): void {
|
||||
getStorage()?.setItem(STORAGE_KEYS.loginToken, token);
|
||||
}
|
||||
|
||||
removeLoginToken(): void {
|
||||
getStorage()?.removeItem(STORAGE_KEYS.loginToken);
|
||||
}
|
||||
|
||||
// ============ 刷新 Token ============
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return getStorage()?.getItem(STORAGE_KEYS.refreshToken) ?? null;
|
||||
}
|
||||
|
||||
setRefreshToken(token: string): void {
|
||||
getStorage()?.setItem(STORAGE_KEYS.refreshToken, token);
|
||||
}
|
||||
|
||||
removeRefreshToken(): void {
|
||||
getStorage()?.removeItem(STORAGE_KEYS.refreshToken);
|
||||
}
|
||||
|
||||
// ============ 游客 Token ============
|
||||
|
||||
getGuestToken(): string | null {
|
||||
return getStorage()?.getItem(STORAGE_KEYS.guestToken) ?? null;
|
||||
}
|
||||
|
||||
setGuestToken(token: string): void {
|
||||
getStorage()?.setItem(STORAGE_KEYS.guestToken, token);
|
||||
}
|
||||
|
||||
removeGuestToken(): void {
|
||||
getStorage()?.removeItem(STORAGE_KEYS.guestToken);
|
||||
}
|
||||
|
||||
// ============ 设备 ID ============
|
||||
|
||||
getDeviceId(): string | null {
|
||||
return getStorage()?.getItem(STORAGE_KEYS.deviceId) ?? null;
|
||||
}
|
||||
|
||||
setDeviceId(deviceId: string): void {
|
||||
getStorage()?.setItem(STORAGE_KEYS.deviceId, deviceId);
|
||||
}
|
||||
|
||||
removeDeviceId(): void {
|
||||
getStorage()?.removeItem(STORAGE_KEYS.deviceId);
|
||||
}
|
||||
|
||||
// ============ 复合操作 ============
|
||||
|
||||
/**
|
||||
* 是否有登录 token
|
||||
*/
|
||||
hasLoginToken(): boolean {
|
||||
const token = this.getLoginToken();
|
||||
return token !== null && token.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证 token(优先登录 token,其次游客 token)
|
||||
*/
|
||||
getAuthToken(): string | null {
|
||||
const loginToken = this.getLoginToken();
|
||||
if (loginToken !== null && loginToken.length > 0) return loginToken;
|
||||
const guestToken = this.getGuestToken();
|
||||
if (guestToken !== null && guestToken.length > 0) return guestToken;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有 auth 数据(登录/刷新/游客 token)
|
||||
*/
|
||||
clearAuthData(): void {
|
||||
this.removeLoginToken();
|
||||
this.removeRefreshToken();
|
||||
this.removeGuestToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例
|
||||
*/
|
||||
export const authStorage = new AuthStorage();
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 设备 ID 工具类
|
||||
*
|
||||
* 使用 fingerprintjs 生成客户端设备指纹。
|
||||
* 原始 Dart: lib/data/services/device_id_service_impl.dart
|
||||
*
|
||||
* 注:仅在浏览器环境可用;SSR/Server Components 中调用会抛错。
|
||||
*/
|
||||
import { authStorage } from "../../storage/auth_storage";
|
||||
|
||||
export class DeviceIdentifier {
|
||||
private cachedId: string | null = null;
|
||||
private initPromise: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* 获取或生成设备 ID
|
||||
*
|
||||
* 优先级:内存缓存 → localStorage 缓存 → fingerprintjs 生成
|
||||
* 并发安全:多个并发调用会共享同一个 fingerprintjs 加载 Promise
|
||||
*/
|
||||
async getDeviceId(): Promise<string> {
|
||||
if (this.cachedId) return this.cachedId;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.initPromise = this.initialize();
|
||||
this.cachedId = await this.initPromise;
|
||||
return this.cachedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置缓存与持久化(用于测试或多账号切换)
|
||||
*/
|
||||
reset(): void {
|
||||
this.cachedId = null;
|
||||
this.initPromise = null;
|
||||
authStorage.removeDeviceId();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<string> {
|
||||
// 1. 优先从 localStorage 读取
|
||||
const stored = authStorage.getDeviceId();
|
||||
if (stored) return stored;
|
||||
|
||||
// 2. SSR/Node 环境守卫:调用方必须保证在浏览器中调用
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error(
|
||||
"DeviceIdentifier only works in browser environment. " +
|
||||
"Call getDeviceId() from a Client Component or browser code."
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 动态加载 fingerprintjs(避免 Next.js SSR 打包分析时执行)
|
||||
const { load } = await import("@fingerprintjs/fingerprintjs");
|
||||
const fp = await load();
|
||||
const result = await fp.get();
|
||||
|
||||
// 4. 持久化
|
||||
authStorage.setDeviceId(result.visitorId);
|
||||
return result.visitorId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例
|
||||
*/
|
||||
export const deviceIdentifier = new DeviceIdentifier();
|
||||
Reference in New Issue
Block a user