首次提交:姜十三论坛

后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
2026-09-12 01:11:05 +08:00
commit 0c2b0d2d6a
62 changed files with 8289 additions and 0 deletions

395
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,395 @@
// 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<string, string> = {}): HeadersInit {
const headers: Record<string, string> = { ...extra };
const csrf = getCSRFToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<boolean> | null = null;
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
async function apiRefresh(): Promise<boolean> {
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<Response> {
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(公开接口,直接请求后端) =====
export async function fetchPosts(
page = 1,
size = 20,
boardId?: number,
sort = "latest",
keyword = ""
): Promise<PostsResponse> {
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" });
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): Promise<{ post: Post }> {
const res = await fetch(`${API_BASE}/api/posts/${id}`, { cache: "no-store" });
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): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, { cache: "no-store" });
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<UserCommentsResponse> {
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<NotificationsResponse> {
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();
}