2026-06-14 18:38:45 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// HTTP API 客户端
|
|
|
|
|
|
// 职责:封装 REST API 请求(auth、conversations 等)
|
2026-06-20 14:57:27 +08:00
|
|
|
|
// 内置 401 拦截 + 自动刷新 + 重试机制
|
2026-06-14 18:38:45 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
const API_BASE = "/api";
|
|
|
|
|
|
|
|
|
|
|
|
interface ApiResponse<T> {
|
|
|
|
|
|
data?: T;
|
|
|
|
|
|
error?: { code: string; message: string };
|
|
|
|
|
|
status: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:57:27 +08:00
|
|
|
|
// ---- 认证回调(由 AuthProvider 注入,避免循环依赖) ----
|
|
|
|
|
|
|
|
|
|
|
|
interface AuthCallbacks {
|
|
|
|
|
|
getAccessToken: () => string | null;
|
|
|
|
|
|
getRefreshToken: () => string | null;
|
|
|
|
|
|
onRefreshSuccess: (user: AuthUser, accessToken: string, refreshToken: string) => void;
|
|
|
|
|
|
onRefreshFailed: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let authCallbacks: AuthCallbacks | null = null;
|
|
|
|
|
|
let refreshPromise: Promise<boolean> | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** 由 AuthProvider 在初始化时调用,注入认证回调。 */
|
|
|
|
|
|
export function setAuthCallbacks(callbacks: AuthCallbacks): void {
|
|
|
|
|
|
authCallbacks = callbacks;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 不需要认证的公开路径。 */
|
|
|
|
|
|
const PUBLIC_PATHS = new Set([
|
|
|
|
|
|
"/auth/register",
|
|
|
|
|
|
"/auth/login",
|
|
|
|
|
|
"/auth/refresh",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
function isPublicPath(path: string): boolean {
|
|
|
|
|
|
return PUBLIC_PATHS.has(path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 尝试用 refresh token 换取新的 access token。 */
|
|
|
|
|
|
async function doRefresh(): Promise<boolean> {
|
|
|
|
|
|
const rt = authCallbacks?.getRefreshToken();
|
|
|
|
|
|
if (!rt) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const res = await refreshTokenDirect(rt);
|
|
|
|
|
|
if (res.data) {
|
|
|
|
|
|
authCallbacks?.onRefreshSuccess(
|
|
|
|
|
|
res.data.user,
|
|
|
|
|
|
res.data.access_token,
|
|
|
|
|
|
res.data.refresh_token
|
|
|
|
|
|
);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
authCallbacks?.onRefreshFailed();
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 带并发保护的刷新:多个 401 只触发一次 refresh。 */
|
|
|
|
|
|
async function refreshWithLock(): Promise<boolean> {
|
|
|
|
|
|
if (!refreshPromise) {
|
|
|
|
|
|
refreshPromise = doRefresh().finally(() => {
|
|
|
|
|
|
refreshPromise = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return refreshPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 核心请求函数 ----
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
async function request<T>(
|
|
|
|
|
|
path: string,
|
2026-06-20 14:57:27 +08:00
|
|
|
|
options: RequestInit = {},
|
|
|
|
|
|
_retry = false
|
2026-06-14 18:38:45 +08:00
|
|
|
|
): Promise<ApiResponse<T>> {
|
|
|
|
|
|
const url = `${API_BASE}${path}`;
|
|
|
|
|
|
const headers: Record<string, string> = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
...(options.headers as Record<string, string>),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-20 14:57:27 +08:00
|
|
|
|
// 对非公开路径自动附加 access token
|
|
|
|
|
|
if (!isPublicPath(path) && !headers["Authorization"]) {
|
|
|
|
|
|
const token = authCallbacks?.getAccessToken();
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
headers["Authorization"] = `Bearer ${token}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, { ...options, headers });
|
|
|
|
|
|
const status = res.status;
|
|
|
|
|
|
|
|
|
|
|
|
if (res.status === 204) {
|
|
|
|
|
|
return { status };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
|
|
2026-06-20 14:57:27 +08:00
|
|
|
|
// 401 拦截:尝试刷新 token 后重试(仅重试一次)
|
|
|
|
|
|
if (res.status === 401 && !_retry && !isPublicPath(path) && authCallbacks) {
|
|
|
|
|
|
const refreshed = await refreshWithLock();
|
|
|
|
|
|
if (refreshed) {
|
|
|
|
|
|
return request<T>(path, options, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
|
|
|
|
|
|
status,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { data: body as T, status };
|
2026-06-20 15:07:09 +08:00
|
|
|
|
} catch {
|
2026-06-14 18:38:45 +08:00
|
|
|
|
return {
|
|
|
|
|
|
error: { code: "NETWORK_ERROR", message: "网络连接失败,请检查网络" },
|
|
|
|
|
|
status: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function authHeaders(accessToken: string): Record<string, string> {
|
|
|
|
|
|
return { Authorization: `Bearer ${accessToken}` };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Auth API ----
|
|
|
|
|
|
|
|
|
|
|
|
export interface AuthUser {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
username: string;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface AuthResponse {
|
|
|
|
|
|
user: AuthUser;
|
|
|
|
|
|
access_token: string;
|
|
|
|
|
|
refresh_token: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function register(
|
|
|
|
|
|
username: string,
|
|
|
|
|
|
password: string
|
|
|
|
|
|
): Promise<ApiResponse<AuthResponse>> {
|
|
|
|
|
|
return request<AuthResponse>("/auth/register", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function login(
|
|
|
|
|
|
username: string,
|
|
|
|
|
|
password: string
|
|
|
|
|
|
): Promise<ApiResponse<AuthResponse>> {
|
|
|
|
|
|
return request<AuthResponse>("/auth/login", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:57:27 +08:00
|
|
|
|
/** 内部用的 refresh 请求,不经过 401 拦截(避免递归)。 */
|
|
|
|
|
|
async function refreshTokenDirect(
|
|
|
|
|
|
refresh_token: string
|
|
|
|
|
|
): Promise<ApiResponse<AuthResponse>> {
|
|
|
|
|
|
const url = `${API_BASE}/auth/refresh`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ refresh_token }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
|
|
|
|
|
|
status: res.status,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { data: body as AuthResponse, status: res.status };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {
|
|
|
|
|
|
error: { code: "NETWORK_ERROR", message: "网络连接失败" },
|
|
|
|
|
|
status: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
export async function refreshToken(
|
|
|
|
|
|
refresh_token: string
|
|
|
|
|
|
): Promise<ApiResponse<AuthResponse>> {
|
|
|
|
|
|
return request<AuthResponse>("/auth/refresh", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
body: JSON.stringify({ refresh_token }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function logout(
|
|
|
|
|
|
accessToken: string,
|
2026-06-20 14:57:27 +08:00
|
|
|
|
refreshTokenStr: string
|
2026-06-14 18:38:45 +08:00
|
|
|
|
): Promise<ApiResponse<{ message: string }>> {
|
|
|
|
|
|
return request<{ message: string }>("/auth/logout", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders(accessToken),
|
2026-06-20 14:57:27 +08:00
|
|
|
|
body: JSON.stringify({ refresh_token: refreshTokenStr }),
|
2026-06-14 18:38:45 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-20 19:57:36 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- Conversation API ----
|
|
|
|
|
|
|
|
|
|
|
|
export interface ConversationListItem {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
last_message: string;
|
|
|
|
|
|
message_count: number;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
updated_at: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface ConversationListResponse {
|
|
|
|
|
|
conversations: ConversationListItem[];
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
size: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface CreateConversationResponse {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
updated_at: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface StoredMessage {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
role: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
tokens_used: number;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface MessagesResponse {
|
|
|
|
|
|
messages: StoredMessage[];
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function listConversations(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
page = 1,
|
|
|
|
|
|
size = 50
|
|
|
|
|
|
): Promise<ApiResponse<ConversationListResponse>> {
|
|
|
|
|
|
return request<ConversationListResponse>(
|
|
|
|
|
|
`/conversations?page=${page}&size=${size}`,
|
|
|
|
|
|
{ headers: authHeaders(token) }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createConversation(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
config?: Record<string, unknown>
|
|
|
|
|
|
): Promise<ApiResponse<CreateConversationResponse>> {
|
|
|
|
|
|
return request<CreateConversationResponse>("/conversations", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
|
body: JSON.stringify(config ? { config } : {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteConversation(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
id: string
|
|
|
|
|
|
): Promise<ApiResponse<void>> {
|
|
|
|
|
|
return request<void>(`/conversations/${id}`, {
|
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function renameConversation(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
title: string
|
|
|
|
|
|
): Promise<ApiResponse<{ message: string }>> {
|
|
|
|
|
|
return request<{ message: string }>(`/conversations/${id}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: authHeaders(token),
|
|
|
|
|
|
body: JSON.stringify({ title }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getConversationMessages(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
limit = 200
|
|
|
|
|
|
): Promise<ApiResponse<MessagesResponse>> {
|
|
|
|
|
|
return request<MessagesResponse>(
|
|
|
|
|
|
`/conversations/${id}/messages?limit=${limit}`,
|
|
|
|
|
|
{ headers: authHeaders(token) }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|