import { CSRF_COOKIE } from "./cookies"; // API 基础配置 // 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端, // 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。 // SSR/middleware 直连后端:优先服务端专用 BACKEND_URL(边缘部署必须显式配置, // localhost 兜底只在本地开发有效),回退到 NEXT_PUBLIC_API_URL。 const API_BASE = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; // SSR 请求超时:后端不可用时快速降级为游客视图,不阻塞页面渲染 const SSR_TIMEOUT_MS = 8000; function ssrInit(headers?: HeadersInit): RequestInit { return { cache: "no-store", headers, signal: AbortSignal.timeout(SSR_TIMEOUT_MS), }; } // 类型定义 export interface User { id: number; username: string; nickname: string; avatar: string; role: string; // 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息) email?: string; signature?: string; } // /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出) export interface MeResponse { user: User | null; unread_count: number; } export interface Board { id: number; name: string; description: string; icon: string; color_index?: number; sort_order: number; } // 首页聚合接口 export interface OverviewStats { posts: number; users: number; comments: number; today_posts: number; today_users: number; today_comments: number; online: number; peak_online: number; } export interface ActiveUser { id: number; username: string; nickname: string; avatar: string; post_count: number; comment_count: number; } export interface BoardWithCount extends Board { post_count: number; } // 公告标签预设色(与后端白名单一致) export type AnnouncementColor = | "blue" | "green" | "orange" | "red" | "purple" | "gray"; // 右栏站点公告摘要(标签/日期/标题) export interface AnnouncementSummary { id: number; title: string; tag: string; tag_color: AnnouncementColor; created_at: string; } // 公告详情/后台管理完整对象 export interface Announcement { id: number; title: string; content: string; tag: string; tag_color: AnnouncementColor; published: boolean; created_at: string; updated_at: string; } export interface NewUser { id: number; username: string; nickname: string; avatar: string; created_at: string; } export interface CheckinStatus { checked_today: boolean; streak: number; total_points: number; today_points: number; } export interface OverviewResponse { stats: OverviewStats; hot: PostListItem[]; active_users: ActiveUser[]; boards: BoardWithCount[]; announcements: AnnouncementSummary[]; new_users: NewUser[]; checkin?: CheckinStatus | null; } 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; last_reply?: { user: Pick; created_at: string; } | null; 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; parent_id?: number | null; // 父评论 ID,null = 主评论(楼层) root_id?: number | null; // 所属楼层 ID,子评论必填 depth?: number; // 层级:主评论 0,子评论 = 父 + 1 content: string; status: string; created_at: string; user: User; } // 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出) export interface CommentNode extends Comment { replies?: CommentNode[]; } // 帖子评论楼层分页响应(时间正序;楼层号 = (page-1)*size + 序号 + 1,仅主评论占楼层) export interface CommentsResponse { comments: CommentNode[]; total: number; // 楼层数(主评论数)→ 分页与楼层号计算 total_comments: number; // 全部评论数(含回复)→ 头部徽标 page: number; size: number; } export interface PostsResponse { posts: PostListItem[]; total: number; page: number; size: number; } export interface UserProfile { user: { id: number; username: string; nickname: string; avatar: string; signature: string; role: string; created_at: string; }; stats: { post_count: number; comment_count: number; points: number; streak: 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" | "reply" | "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,前端可读;生产名为 __Host- 前缀) function getCSRFToken(): string { if (typeof document === "undefined") return ""; const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`)); 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 = "", recommended = false, 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); if (recommended) params.set("recommended", "true"); const res = await fetch(`${API_BASE}/api/posts?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } // SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块 export async function fetchOverview(cookieHeader?: string): Promise { const res = await fetch(`${API_BASE}/api/overview`, ssrInit(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`, ssrInit()); 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}`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } export async function fetchComments(postId: string, page = 1): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/posts/${postId}/comments?${params}`, ssrInit()); 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}`, ssrInit(cookieHeaders(cookieHeader))); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取用户资料失败"); return res.json(); } // SSR 端获取当前登录态(用户信息 + 未读通知数合并为一次请求), // 供 layout 直出右上角用户区与铃铛红点,避免客户端水合后再切换 export async function fetchMe(cookieHeader?: string): Promise { try { const res = await fetch(`${API_BASE}/api/me`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) return { user: null, unread_count: 0 }; return res.json(); } catch { // 超时/后端不可用:降级游客视图,不阻塞页面渲染 return { user: null, unread_count: 0 }; } } 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}`, ssrInit()); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取评论失败"); return res.json(); } // SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导) export async function fetchNotifications(page = 1, cookieHeader?: string): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); const res = await fetch(`${API_BASE}/api/notifications?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (res.status === 401) throw new Error("未登录"); if (!res.ok) throw new Error("获取通知失败"); return res.json(); } // ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) ===== // 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 }) export async function apiMe(): Promise { 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, parentId?: number) { const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, { method: "POST", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(parentId ? { content, parent_id: parentId } : { 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 apiUpdateProfile(data: { nickname: string; avatar?: string; email?: string; signature?: string; }): Promise<{ user?: User; error?: string }> { const res = await fetchWithRefresh("/api/profile", { method: "PUT", headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(data), }); 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(); } // ===== 每日签到 ===== export async function apiGetCheckin(): Promise { const res = await fetchWithRefresh("/api/checkin", { headers: clientHeaders(), }); if (!res.ok) throw new Error("获取签到状态失败"); return res.json(); } // 执行签到;失败(如今日已签,409)抛出后端消息 export async function apiDoCheckin(): Promise { const res = await fetchWithRefresh("/api/checkin", { method: "POST", headers: clientHeaders(), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "签到失败"); return data as CheckinStatus; } // ===== 站点公告 ===== // SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理) export async function fetchAnnouncementDetail( id: string ): Promise<{ announcement: Announcement } | null> { const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit()); if (res.status === 404) return null; if (!res.ok) throw new Error("获取公告失败"); return res.json(); } // 后台:公告列表(含草稿) export async function apiAdminListAnnouncements(): Promise<{ announcements: Announcement[] }> { const res = await fetchWithRefresh("/api/admin/announcements", { headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "获取公告列表失败"); } return res.json(); } export interface AnnouncementInput { title: string; content: string; tag: string; tag_color: AnnouncementColor; published: boolean; } async function saveAnnouncement( method: "POST" | "PUT", url: string, input: AnnouncementInput ): Promise<{ announcement: Announcement }> { const res = await fetchWithRefresh(url, { method, headers: clientHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(input), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "保存公告失败"); return data as { announcement: Announcement }; } export function apiAdminCreateAnnouncement(input: AnnouncementInput) { return saveAnnouncement("POST", "/api/admin/announcements", input); } export function apiAdminUpdateAnnouncement(id: number, input: AnnouncementInput) { return saveAnnouncement("PUT", `/api/admin/announcements/${id}`, input); } export async function apiAdminDeleteAnnouncement(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/announcements/${id}`, { method: "DELETE", headers: clientHeaders(), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "删除公告失败"); } }