Files
CamTalk/frontend/src/lib/api.ts

304 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// HTTP API 客户端
// 职责:封装 REST API 请求auth、conversations 等)
// 内置 401 拦截 + 自动刷新 + 重试机制
// ============================================================
const API_BASE = "/api";
interface ApiResponse<T> {
data?: T;
error?: { code: string; message: string };
status: number;
}
// ---- 认证回调(由 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;
}
// ---- 核心请求函数 ----
async function request<T>(
path: string,
options: RequestInit = {},
_retry = false
): Promise<ApiResponse<T>> {
const url = `${API_BASE}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
// 对非公开路径自动附加 access token
if (!isPublicPath(path) && !headers["Authorization"]) {
const token = authCallbacks?.getAccessToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
try {
const res = await fetch(url, { ...options, headers });
const status = res.status;
if (res.status === 204) {
return { status };
}
const body = await res.json();
// 401 拦截:尝试刷新 token 后重试(仅重试一次)
if (res.status === 401 && !_retry && !isPublicPath(path) && authCallbacks) {
const refreshed = await refreshWithLock();
if (refreshed) {
return request<T>(path, options, true);
}
}
if (!res.ok) {
return {
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
status,
};
}
return { data: body as T, status };
} catch {
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 }),
});
}
/** 内部用的 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,
};
}
}
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,
refreshTokenStr: string
): Promise<ApiResponse<{ message: string }>> {
return request<{ message: string }>("/auth/logout", {
method: "POST",
headers: authHeaders(accessToken),
body: JSON.stringify({ refresh_token: refreshTokenStr }),
});
}
// ---- 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) }
);
}