// API 基础配置 // 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端, // 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。 // SSR 端(fetchPosts 等公开接口)直接请求后端 API_BASE。 const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; // 类型定义 export interface User { id: number; username: string; nickname: string; avatar: string; role: string; } export interface Board { id: number; name: string; description: string; icon: string; sort_order: number; } export interface PostListItem { id: number; board_id: number; user_id: number; title: string; tags: string; post_type: string; pinned: number; recommended: boolean; like_count: number; view_count: number; comment_count: number; liked: boolean; created_at: string; board: Board; user: User; } export interface Post { id: number; board_id: number; user_id: number; title: string; content: string; tags: string; post_type: string; pinned: number; recommended: boolean; status: string; like_count: number; view_count: number; comment_count: number; liked: boolean; created_at: string; updated_at: string; board: Board; user: User; } export interface Comment { id: number; post_id: number; user_id: number; content: string; status: string; created_at: string; user: User; } export interface PostsResponse { posts: PostListItem[]; total: number; page: number; size: number; } export interface UserProfile { user: { id: number; username: string; nickname: string; avatar: string; role: string; created_at: string; }; stats: { post_count: number; comment_count: number; }; posts: PostListItem[]; posts_total: number; page: number; size: number; } export interface UserCommentItem { id: number; post_id: number; post_title: string; content: string; created_at: string; } export interface UserCommentsResponse { comments: UserCommentItem[]; total: number; page: number; size: number; } export interface NotificationItem { id: number; user_id: number; actor_id: number; type: "comment" | "like"; post_id: number; comment_id: number; content: string; is_read: boolean; created_at: string; actor: User; post: { id: number; title: string }; } export interface NotificationsResponse { notifications: NotificationItem[]; total: number; page: number; size: number; } // 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读) function getCSRFToken(): string { if (typeof document === "undefined") return ""; const match = document.cookie.match(/(?:^|;\s*)j13_csrf=([^;]+)/); return match ? decodeURIComponent(match[1]) : ""; } // 构造客户端 fetch 的通用 headers(含 CSRF token) function clientHeaders(extra: Record = {}): HeadersInit { const headers: Record = { ...extra }; const csrf = getCSRFToken(); if (csrf) headers["X-CSRF-Token"] = csrf; return headers; } // ===== Refresh token 自动续期 ===== let refreshPromise: Promise | null = null; // 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带) async function apiRefresh(): Promise { try { const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", headers: clientHeaders(), }); return res.ok; } catch { return false; } } // 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求 async function fetchWithRefresh(url: string, init: RequestInit): Promise { let res = await fetch(url, { ...init, credentials: "include" }); if (res.status === 401) { // 串行化 refresh:多个并发 401 只触发一次 refresh if (!refreshPromise) { refreshPromise = apiRefresh().finally(() => { refreshPromise = null; }); } const refreshed = await refreshPromise; if (refreshed) { // refresh 成功,重试原请求(CSRF cookie 可能已更新) const newHeaders = clientHeaders(); const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders }; res = await fetch(url, mergedInit); } } return res; } // ===== SSR 端 fetch(公开接口,直接请求后端) ===== // SSR 页面通过 (await cookies()).toString() 传入,后端据此识别登录用户并填充 liked 状态 function cookieHeaders(cookieHeader?: string): HeadersInit | undefined { return cookieHeader ? { Cookie: cookieHeader } : undefined; } export async function fetchPosts( page = 1, size = 20, boardId?: number, sort = "latest", keyword = "", cookieHeader?: string ): Promise { const params = new URLSearchParams({ page: String(page), size: String(size), sort }); if (boardId) params.set("board_id", String(boardId)); if (keyword) params.set("keyword", keyword); const res = await fetch(`${API_BASE}/api/posts?${params}`, { cache: "no-store", headers: cookieHeaders(cookieHeader), }); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } export async function fetchBoards(): Promise<{ boards: Board[] }> { const res = await fetch(`${API_BASE}/api/boards`, { cache: "no-store" }); if (!res.ok) throw new Error("获取板块失败"); return res.json(); } export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> { const res = await fetch(`${API_BASE}/api/posts/${id}`, { cache: "no-store", headers: cookieHeaders(cookieHeader), }); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } export async function fetchComments(postId: string): Promise<{ comments: Comment[] }> { const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, { cache: "no-store" }); if (!res.ok) throw new Error("获取评论失败"); return res.json(); } export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, { cache: "no-store", headers: cookieHeaders(cookieHeader), }); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取用户资料失败"); return res.json(); } export async function fetchUserComments(id: string, page = 1): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, { cache: "no-store" }); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取评论失败"); return res.json(); } // ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) ===== // 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null }) export async function apiMe(): Promise<{ user: User | null }> { const res = await fetch("/api/me", { credentials: "include", cache: "no-store", }); return res.json(); } export async function apiLogin(username: string, password: string) { const res = await fetch("/api/login", { method: "POST", credentials: "include", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ username, password }), }); return res.json(); } export async function apiRegister(username: string, email: string, password: string) { const res = await fetch("/api/register", { method: "POST", credentials: "include", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ username, email, password }), }); return res.json(); } export async function apiLogout() { const res = await fetchWithRefresh("/api/logout", { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiCreatePost(data: { board_id: number; title: string; content: string; tags?: string; post_type?: string; }) { const res = await fetchWithRefresh("/api/posts", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); return res.json(); } export async function apiCreateComment(postId: string, content: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ content }), }); return res.json(); } export async function apiUpdatePost( postId: string, data: { title?: string; content?: string; tags?: string } ) { const res = await fetchWithRefresh(`/api/posts/${postId}`, { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); return res.json(); } export async function apiDeletePost(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}`, { method: "DELETE", headers: clientHeaders(), }); return res.json(); } export async function apiTogglePin(postId: string): Promise<{ pinned: number }> { const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } export async function apiToggleRecommend(postId: string): Promise<{ recommended: boolean }> { const res = await fetchWithRefresh(`/api/posts/${postId}/recommend`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } export async function apiChangePassword(oldPassword: string, newPassword: string) { const res = await fetchWithRefresh("/api/change-password", { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), }); return res.json(); } export async function apiToggleLike(postId: string) { const res = await fetchWithRefresh(`/api/posts/${postId}/like`, { method: "POST", headers: clientHeaders(), }); return res.json(); } export async function apiDeleteComment(postId: string, commentId: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, { method: "DELETE", headers: clientHeaders(), }); return res.json(); } // ===== 通知 ===== export async function apiFetchNotifications(page = 1): Promise { const res = await fetchWithRefresh(`/api/notifications?page=${page}&size=20`, { headers: clientHeaders(), }); return res.json(); } export async function apiUnreadCount(): Promise<{ count: number }> { const res = await fetchWithRefresh("/api/notifications/unread-count", { headers: clientHeaders(), }); return res.json(); } export async function apiMarkRead(id: number) { const res = await fetchWithRefresh(`/api/notifications/${id}/read`, { method: "PUT", headers: clientHeaders(), }); return res.json(); } export async function apiMarkAllRead() { const res = await fetchWithRefresh("/api/notifications/read-all", { method: "PUT", headers: clientHeaders(), }); return res.json(); }