From 833bcd33a16b4b473cfe3e112f9fd5494bd0de34 Mon Sep 17 00:00:00 2001
From: freefire
Date: Wed, 16 Sep 2026 05:04:47 +0800
Subject: [PATCH] =?UTF-8?q?chore:=20=E5=8E=BB=E6=8E=89=E6=9C=AA=E4=B8=8A?=
=?UTF-8?q?=E7=BA=BF=E5=89=8D=E7=9A=84=E6=97=A7=E5=85=BC=E5=AE=B9=E5=9E=AB?=
=?UTF-8?q?=E7=89=87=EF=BC=8C=E6=9D=BF=E5=9D=97=E6=B5=81=E6=94=B6=E5=8F=A3?=
=?UTF-8?q?=E5=88=B0=E9=A6=96=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。
Co-authored-by: Cursor
---
.cursor/project_memory.md | 12 +-
.cursor/rules/jiang13-bbs-auth-realtime.mdc | 1 +
.cursor/rules/jiang13-bbs-core.mdc | 1 +
.cursor/rules/jiang13-bbs-engineering.mdc | 1 +
README.md | 25 +-
backend/handler/announcement.go | 2 +-
backend/handler/auth.go | 31 +-
backend/handler/board.go | 44 +++
backend/handler/realtime.go | 21 +-
backend/handler/seo.go | 91 ------
backend/middleware/auth.go | 34 +-
backend/middleware/security.go | 3 -
backend/model/db.go | 6 -
backend/model/models.go | 8 +-
backend/router/router.go | 11 +-
backend/service/board.go | 166 ++++++++++
backend/service/post.go | 4 +-
.../app/admin/moderation/ModerationAdmin.tsx | 4 +-
frontend/app/board/[id]/page.tsx | 156 ---------
frontend/app/boards/page.tsx | 108 ------
frontend/app/layout.tsx | 11 +-
frontend/app/not-found.tsx | 4 +-
frontend/app/page.tsx | 95 ++++--
frontend/app/post/[id]/page.tsx | 14 +-
frontend/app/sitemap.ts | 12 +-
frontend/components/Header.tsx | 18 +-
frontend/components/PostRow.tsx | 2 +-
frontend/components/ThemeSync.tsx | 2 +-
frontend/components/home/BoardPanels.tsx | 307 ++++++++++++++++++
frontend/components/home/WorkspaceNav.tsx | 22 +-
frontend/lib/api.ts | 43 ++-
frontend/lib/postMeta.ts | 2 +-
frontend/lib/realtime.ts | 2 +-
33 files changed, 715 insertions(+), 548 deletions(-)
delete mode 100644 backend/handler/seo.go
delete mode 100644 frontend/app/board/[id]/page.tsx
delete mode 100644 frontend/app/boards/page.tsx
create mode 100644 frontend/components/home/BoardPanels.tsx
diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md
index 6d8c77f..2b3241e 100644
--- a/.cursor/project_memory.md
+++ b/.cursor/project_memory.md
@@ -13,6 +13,7 @@
4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
5. 权限逻辑只在 Go 端,前端不做权限判定
6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
+7. 用户未明确告知「已正式部署到生产环境」之前:禁止为旧版本/旧契约写兼容冗余(双写双读、fallback、过渡字段、旧 cookie/旧 API 垫片);直接改到位并删旧代码,保持源码干净规范
---
@@ -72,7 +73,7 @@
- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400
- 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列
- 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400
-- 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(30s 角标轮询仅入口可见时);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...]
+- 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(待审角标 WS `moderation:changed` + 回前台 HTTP 校准,无定时轮询);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...]
- 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组
---
@@ -82,12 +83,12 @@
**不使用 localStorage 存储 token**(XSS 可窃取),采用三 Cookie + Token Version 方案:
- `j13_token`:access token JWT,`HttpOnly + SameSite=Lax`,**15 分钟**过期,JS 不可读
-- `j13_refresh`:refresh token(DB 存储),`HttpOnly + SameSite=Lax`,`Path=/`(2026-09 起从 /api/auth 放宽,供 Next middleware 在页面请求中续期;clearAuthCookies 同时清 / 与 /api/auth 兼容旧 cookie),**7 天**过期
+- `j13_refresh`:refresh token(DB 存储),`HttpOnly + SameSite=Lax`,`Path=/`(供 Next middleware 在页面请求中续期),**7 天**过期
- `j13_csrf`:CSRF token,非 HttpOnly(JS 可读),`SameSite=Lax`,7 天过期
**后端**(`service/auth.go`、`middleware/auth.go`、`middleware/csrf.go`、`middleware/security.go`):
- `User.TokenVersion` 字段:改密码/封禁/管理员强制下线时递增,使所有 JWT 立即失效
-- `parseToken` 优先从 `j13_token` cookie 读取,回退到 Authorization header
+- `parseToken` 只从 `j13_token` cookie 读取(不接受 Authorization Bearer)
- `ValidateClaims` 每请求查 DB:校验 `token_version` 匹配 + `banned` 状态(不依赖 JWT 缓存值)
- `CSRFMiddleware` 对非 GET 方法校验 `X-CSRF-Token` header 与 `j13_csrf` cookie 一致
- 需登录 API 组挂载顺序:`RequireAuth → CSRFMiddleware`(未登录返回 401 而非 403)
@@ -124,7 +125,7 @@
- 三期(已完成 2026-09-15):帖子流「有新内容」提示 —— `feed:changed` 全员广播(staff 直发/审核通过帖评),首页与板块页 tip,点击 `router.refresh()`;不做自动插行/游客轮询
-- 端点 `GET /api/ws`(gorilla/websocket v1.5.3,挂在 r.Static 之后、pubAPI 之前,不走任何中间件组,鉴权在处理器内):cookie j13_token 优先、Bearer 回退,ParseToken+ValidateClaims 双校验失败 401 JSON;checkWSOrigin:无 Origin 放行,dev 允许 localhost/127.0.0.1 任意端口,生产要求 Origin host==Host
+- 端点 `GET /api/ws`(gorilla/websocket v1.5.3,挂在 r.Static 之后、pubAPI 之前,不走任何中间件组,鉴权在处理器内):只读 cookie j13_token,ParseToken+ValidateClaims 双校验失败 401 JSON;checkWSOrigin:**必须带 Origin**,dev 允许 localhost/127.0.0.1 任意端口(页面 :3000 直连 :3001),生产要求 Origin host==Host(须反代同源)
- 代码:`backend/realtime/hub.go`(Hub:clients/rooms/userConnCnt 多标签页计数;register 0→1 才上线广播,最后一条连接断开才下线广播;房间 staff、个人房 user:{id};dispatch 满缓冲 forceClose 慢消费者;OnlineUserIDs 快照)、`backend/realtime/client.go`(writePump 唯一写连接:30s 协议 Ping、70s pongWait、send buf 32;readPump 只认 {"type":"ping"} 应用帧,每次心跳复查 ValidateClaims+TouchLastSeen(60s SQL 限频),失败写 **关闭码 4401**)、`handler/realtime.go`;Handlers.Hub 在 router.NewHub()
- 信封单层 JSON `{type,data}`(encoding/json/v2);事件:`hello`(staff 的 data 带 online_user_ids 在线快照)、`pong`、`settings:changed`{accent}(BroadcastAll)、`presence:update`{user_id,online}(只 BroadcastRoom staff);UpdateSettings 保存后广播,accent 为归一化小写 trim 值
- **在线判定新口径:有活跃 WS 即在线**(取代 last_seen_at 5 分钟启发式,更精确;用户管理页绿点由 presence 推送+hello 快照 reconcile,SSR initial.online 仍走旧口径)
@@ -219,6 +220,7 @@
---
## Hard Constraints
+- 未正式上线前禁止旧兼容冗余:用户明确说「已正式部署到生产环境」之前,改契约直接改到位并删旧路径,禁止双写/双读/fallback/过渡字段等垫片
- Main comments count as floors; sub-comments do not count as floors
- Comment depth is limited to 10 levels
- 帖子详情页三栏宽度与首页统一,容器同为 max-w-[1440px]、间距同为 gap-6 / xl:gap-7
@@ -248,7 +250,7 @@
- 管理员侧边栏导航在 `app/admin/AdminNav.tsx` 中维护,新增模块通过修改 `NAV_ITEMS` 实现
- 用户管理列表支持分页(默认 20,上限 50)、用户名/昵称/邮箱 ILIKE 搜索、角色与状态筛选
- 用户管理操作后自动重新拉取数据以保证计数一致性;查询状态同步到 URL 参数(`?q=&filter=&page=`)
-- 管理员 API 路由需依次经过 RequireAuth → CSRF → RequireAdmin 中间件
+- 管理员 API 路由经 RequireStaff → CSRF,功能点再由 RequirePerm 放行
- 用户管理列表展示自己账号时仅显示"当前账号"标识,无操作按钮
- 危险操作(如封禁、降级)需显示明确的 confirm 提示文案
- 移动端用户管理列表使用卡片布局,桌面端使用七列表格布局
diff --git a/.cursor/rules/jiang13-bbs-auth-realtime.mdc b/.cursor/rules/jiang13-bbs-auth-realtime.mdc
index 980725a..6aaf430 100644
--- a/.cursor/rules/jiang13-bbs-auth-realtime.mdc
+++ b/.cursor/rules/jiang13-bbs-auth-realtime.mdc
@@ -15,6 +15,7 @@ alwaysApply: true
## WebSocket(全站一条连接)
- `GET /api/ws`;dev 直连 `ws://hostname:3001/api/ws`(Next rewrite 不透传 Upgrade)
+- Origin **必须存在**;dev 允许 localhost/127.0.0.1 任意端口;生产要求 Origin host == 请求 Host(页面与 `/api/ws` 须同一 Host)
- 事件:`hello` / `pong` / `settings:changed` / `presence:update` / `chat:*` / `notification:new` / `feed:changed` / `moderation:changed`
- 消息铁律:**先落库再 WS 广播**;WS 不可靠投递;未读靠 HTTP 对账
- 角标优先推送:通知落库统一 `OnNotifyNew`;待审变化推 staff `moderation:changed`;**禁止**再恢复 30s 轮询未读/待审
diff --git a/.cursor/rules/jiang13-bbs-core.mdc b/.cursor/rules/jiang13-bbs-core.mdc
index 6914681..4b1da81 100644
--- a/.cursor/rules/jiang13-bbs-core.mdc
+++ b/.cursor/rules/jiang13-bbs-core.mdc
@@ -15,6 +15,7 @@ alwaysApply: true
4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
5. 权限逻辑只在 Go 端,前端不做权限判定
6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
+7. 用户未明确告知「已正式部署到生产环境」之前:禁止为旧版本/旧契约写兼容冗余(双写双读、fallback、过渡字段、旧 cookie/旧 API 垫片);直接改到位并删旧代码,保持源码干净规范
## 技术栈
diff --git a/.cursor/rules/jiang13-bbs-engineering.mdc b/.cursor/rules/jiang13-bbs-engineering.mdc
index be880f7..34e558c 100644
--- a/.cursor/rules/jiang13-bbs-engineering.mdc
+++ b/.cursor/rules/jiang13-bbs-engineering.mdc
@@ -7,6 +7,7 @@ alwaysApply: true
## Hard Constraints
+- **未正式上线前禁止旧兼容冗余**:在用户明确告知「已正式部署到生产环境」之前,改 API / cookie / schema / 前端契约时直接改到位并删旧路径;禁止双写、双读、fallback、过渡字段、兼容旧 cookie Path、兼容旧客户端等垫片。源码保持干净、规范;生产上线后再谈兼容。
- 主评论计楼层,子评论不计;评论深度上限 10
- 详情页三栏宽度与首页统一:`max-w-[1440px]`、`gap-6` / `xl:gap-7`
- 禁止操作自己的管理员账号;至少保留一名未封禁管理员;角色白名单
diff --git a/README.md b/README.md
index cb44325..e4c2b65 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
### 通知
- 站内通知:评论你的帖子、回复你的评论、点赞你的帖子
-- 未读红点 SSR 直出 + 30s 轮询校正
+- 未读红点 SSR 直出,实时靠 WebSocket 推送,回前台时 HTTP 校准
- 单条标记已读 / 全部已读
### 管理后台
@@ -52,7 +52,7 @@
- 全站 SSR/ISR,SEO 关键内容在初始 HTML 中
- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD
-- 动态 `sitemap.ts` + `robots.ts`
+- 动态 `sitemap.ts` + `robots.ts`(由 Next.js 对外提供,后端不重复挂 `/sitemap.xml` / `/robots.txt`)
### 安全
@@ -191,6 +191,8 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
生产 HTTPS 下 cookie 名自动启用 `__Host-` 前缀。
+若本机曾跑过早期构建,浏览器里可能还留着 `Path=/api/auth` 的 `j13_refresh`(HttpOnly,页面脚本删不掉)。打开开发者工具 → Application → Cookies,删掉该条,或对该源执行「清除站点数据」。之后只应存在 `Path=/` 的三枚 cookie。
+
---
## 部署
@@ -201,6 +203,25 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
2. **前端:** 设置 `BACKEND_URL` 为后端 HTTPS 地址,`npm run build` 后部署。Cloudflare Workers 部署需保持 `middleware.ts` 文件名不变(OpenNext 不识别 `proxy.ts`)。
3. **数据库:** 修改默认密码,启用 SSL 连接。
4. **CORS:** 生产环境修改 `router.go` 中的 `AllowOrigins` 为实际域名。
+5. **WebSocket:** 必须与页面同一 Host(见下节),不要把 API 单独放到另一个子域。
+
+### WebSocket(开发 vs 生产)
+
+浏览器发起 WebSocket 握手**一定会带 `Origin`**。后端拒绝空 Origin,这只挡住 curl / 脚本探测,**不影响 `npm run dev`,也不影响生产浏览器**。
+
+**开发环境(`DEV_MODE=true`)**
+
+- 页面在 `http://localhost:3000`,WS 直连 `ws://localhost:3001/api/ws`(Next.js rewrite 不透传 `Upgrade`)。
+- 二者端口不同,不是浏览器意义上的同源。后端因此放行 Origin 主机为 `localhost` / `127.0.0.1` 的**任意端口**。
+- Cookie 不按端口隔离,`:3000` 上的 `j13_token` 会随握手带到 `:3001`。不要把 `DEV_MODE` 改成 `false` 再跑这对端口,否则 Origin `localhost:3000` 对不上 Host `localhost:3001`,握手会被拒。
+
+**生产环境(`DEV_MODE=false`)**
+
+- Origin 的 host(含端口)必须与请求 `Host` **完全一致**。请用 Nginx/Caddy 把前端与 `/api/`、`/api/ws` 挂到同一域名,例如 `https://bbs.example.com`。
+- 反向代理需透传 `Upgrade`、`Connection`,并保留原始 `Host` / `Origin`。
+- 生产 cookie 使用 `__Host-` 前缀,要求 `Secure`、`Path=/`、**不能设 Domain**。因此 API **不能**放到 `api.example.com` 这类另一子域,否则登录 cookie 与 WS 都会失效。
+- Cloudflare Workers / OpenNext **不能**透传任意 WebSocket。若前端部署在 Workers,实时通道必须由同源反代(Nginx 等)承接 `/api/ws`,而不是指望 Workers 升级连接。
+- 可用 `NEXT_PUBLIC_WS_URL` 覆盖前端连线地址;一旦覆盖,Origin 仍须与后端看到的 Host 一致。
---
diff --git a/backend/handler/announcement.go b/backend/handler/announcement.go
index e6cac85..6d17a40 100644
--- a/backend/handler/announcement.go
+++ b/backend/handler/announcement.go
@@ -41,7 +41,7 @@ func (h *Handlers) AnnouncementDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"announcement": a})
}
-// ===== 管理接口(RequireAdmin 兜底,前端不做权限判定) =====
+// ===== 管理接口(RequireStaff + PermAnnouncements,前端不做权限判定) =====
// AdminListAnnouncements 全部公告(含草稿)
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
diff --git a/backend/handler/auth.go b/backend/handler/auth.go
index 596106e..a919f70 100644
--- a/backend/handler/auth.go
+++ b/backend/handler/auth.go
@@ -57,34 +57,23 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
})
}
-// clearAuthCookies 清除所有认证 cookie。
-// 除当前名称外,还需兼容清除:
-// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/)
-// - 更早期 Path=/api/auth 的旧 refresh cookie
+// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。
func clearAuthCookies(c *gin.Context, secure bool) {
- cookies := []struct{ name, path string }{
- {service.CookieName, "/"},
- {service.RefreshCookieName, "/"},
- {service.CSRFCookieName, "/"},
+ cookies := []struct {
+ name string
+ httpOnly bool
+ }{
+ {service.CookieName, true},
+ {service.RefreshCookieName, true},
+ {service.CSRFCookieName, false},
}
- // 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中
- if service.CookieName != "j13_token" {
- cookies = append(cookies,
- struct{ name, path string }{"j13_token", "/"},
- struct{ name, path string }{"j13_refresh", "/"},
- struct{ name, path string }{"j13_csrf", "/"},
- )
- }
- // 更早期的 refresh cookie 只挂在 /api/auth 下
- cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"})
-
for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
Value: "",
- Path: ck.path,
+ Path: "/",
MaxAge: -1,
- HttpOnly: true,
+ HttpOnly: ck.httpOnly,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
diff --git a/backend/handler/board.go b/backend/handler/board.go
index 0e75520..b382e17 100644
--- a/backend/handler/board.go
+++ b/backend/handler/board.go
@@ -1,9 +1,13 @@
package handler
import (
+ "errors"
"net/http"
+ "strconv"
+ "github.com/freefire/jiang13-bbs/middleware"
"github.com/gin-gonic/gin"
+ "gorm.io/gorm"
)
// Boards 获取板块列表
@@ -15,3 +19,43 @@ func (h *Handlers) Boards(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
}
+
+// BoardSidebar 首页选中板块后的右栏聚合数据
+func (h *Handlers) BoardSidebar(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"})
+ return
+ }
+ data, err := h.Board.Sidebar(uint(id))
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ // 热门榜点赞态 + 登录用户签到
+ if claims := middleware.CurrentUser(c); claims != nil {
+ ids := make([]uint, 0, len(data.Hot))
+ for _, p := range data.Hot {
+ ids = append(ids, p.ID)
+ }
+ likedMap := h.Like.BatchHasLiked(ids, claims.ID)
+ for i := range data.Hot {
+ data.Hot[i].Liked = likedMap[data.Hot[i].ID]
+ }
+ if status, err := h.Checkin.Status(claims.ID); err == nil {
+ data.Checkin = status
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "board": data.Board,
+ "moderators": data.Moderators,
+ "stats": data.Stats,
+ "hot": data.Hot,
+ "active_users": data.ActiveUsers,
+ "checkin": data.Checkin,
+ })
+}
diff --git a/backend/handler/realtime.go b/backend/handler/realtime.go
index bf2e414..3c73d98 100644
--- a/backend/handler/realtime.go
+++ b/backend/handler/realtime.go
@@ -25,18 +25,13 @@ func parseWSChatRoom(room string) (uint, bool) {
// 实时总线 WebSocket 入口:GET /api/ws
//
-// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带),
-// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。
+// 鉴权:与 REST 一致,读 HttpOnly 的 j13_token cookie(浏览器握手自动携带);
+// ParseToken + ValidateClaims 实时校验 token_version 与封禁。
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
// 防止跨站页面在用户浏览器中发起握手。
func (h *Handlers) RealtimeWS(c *gin.Context) {
tokenStr, err := c.Cookie(service.CookieName)
if err != nil || tokenStr == "" {
- if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
- tokenStr = strings.TrimPrefix(auth, "Bearer ")
- }
- }
- if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
@@ -105,17 +100,17 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
})
}
-// checkWSOrigin 校验握手 Origin:
-// - 无 Origin(非浏览器/native 客户端)放行
-// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001)
-// - 生产:Origin 主机必须与请求 Host 同源
+// checkWSOrigin 校验握手 Origin(仅服务浏览器前端):
+// - 必须带 Origin(浏览器 WS 握手总会带;拒绝空 Origin 的脚本/非浏览器探测)
+// - dev:允许 localhost / 127.0.0.1 任意端口(页面 :3000 直连后端 :3001,端口不同但 cookie 仍会带上)
+// - 生产:Origin 的 host 必须与请求 Host 完全一致(需反向代理把页面与 /api/ws 挂到同一 Host)
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
- return true
+ return false
}
u, err := url.Parse(origin)
- if err != nil || u.Scheme != "http" && u.Scheme != "https" {
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return false
}
if h.Cfg.DevMode {
diff --git a/backend/handler/seo.go b/backend/handler/seo.go
deleted file mode 100644
index 3beaf27..0000000
--- a/backend/handler/seo.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package handler
-
-import (
- "encoding/xml"
- "fmt"
- "net/http"
- "time"
-
- "github.com/freefire/jiang13-bbs/model"
- "github.com/gin-gonic/gin"
-)
-
-const siteBaseURL = "http://localhost:3000"
-
-// RobotsTxt robots.txt
-func (h *Handlers) RobotsTxt(c *gin.Context) {
- content := fmt.Sprintf(`User-agent: *
-Allow: /
-Sitemap: %s/sitemap.xml
-`, siteBaseURL)
- c.String(http.StatusOK, content)
-}
-
-// SitemapXML 生成 sitemap
-func (h *Handlers) SitemapXML(c *gin.Context) {
- type urlEntry struct {
- Loc string `xml:"loc"`
- Lastmod string `xml:"lastmod"`
- Changefreq string `xml:"changefreq"`
- Priority string `xml:"priority"`
- }
- type urlset struct {
- XMLName xml.Name `xml:"urlset"`
- Xmlns string `xml:"xmlns,attr"`
- URLs []urlEntry `xml:"url"`
- }
-
- now := time.Now().Format("2006-01-02")
- entries := []urlEntry{
- {Loc: siteBaseURL + "/", Lastmod: now, Changefreq: "daily", Priority: "1.0"},
- }
-
- // 板块页
- var boards []model.Board
- model.DB.Find(&boards)
- for _, b := range boards {
- entries = append(entries, urlEntry{
- Loc: fmt.Sprintf("%s/board/%d", siteBaseURL, b.ID),
- Lastmod: now,
- Changefreq: "daily",
- Priority: "0.8",
- })
- }
-
- // 帖子详情页
- var posts []model.Post
- model.DB.Where("status = ?", model.ContentStatusPublished).
- Order("created_at DESC").Limit(1000).Find(&posts)
- for _, p := range posts {
- entries = append(entries, urlEntry{
- Loc: fmt.Sprintf("%s/post/%d", siteBaseURL, p.ID),
- Lastmod: p.UpdatedAt.Format("2006-01-02"),
- Changefreq: "weekly",
- Priority: "0.6",
- })
- }
-
- // 站点公告
- var anns []model.Announcement
- model.DB.Where("published = ?", true).Order("created_at DESC").Limit(200).Find(&anns)
- for _, a := range anns {
- entries = append(entries, urlEntry{
- Loc: fmt.Sprintf("%s/announcement/%d", siteBaseURL, a.ID),
- Lastmod: a.UpdatedAt.Format("2006-01-02"),
- Changefreq: "weekly",
- Priority: "0.5",
- })
- }
-
- us := urlset{
- Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
- URLs: entries,
- }
- output, err := xml.MarshalIndent(us, "", " ")
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.Header("Content-Type", "application/xml")
- c.String(http.StatusOK, xml.Header+string(output))
-}
diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go
index 1a5fb60..159963a 100644
--- a/backend/middleware/auth.go
+++ b/backend/middleware/auth.go
@@ -3,9 +3,7 @@ package middleware
import (
"errors"
"net/http"
- "strings"
- "github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -60,27 +58,6 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
}
}
-// RequireAdmin 必须管理员(保留兼容;等价于"管理员及以上",不含纯板块管理员)
-func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
- return func(c *gin.Context) {
- user, ok := m.parseToken(c)
- if !ok {
- if c.GetBool(AccountBannedKey) {
- bannedJSON(c)
- return
- }
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
- return
- }
- if model.RoleLevel(model.Role(user.Role)) < model.RoleLevel(model.RoleAdmin) {
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
- return
- }
- c.Set("user", user)
- c.Next()
- }
-}
-
// ActorKey 实时权限快照在 gin.Context 中的键
const ActorKey = "actor"
@@ -121,20 +98,13 @@ func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
}
// parseToken 解析并校验 token:
-// 1. 优先从 HttpOnly cookie 读取,回退 Authorization header
+// 1. 从 HttpOnly cookie(j13_token / 生产 __Host-j13_token)读取
// 2. 校验 JWT 签名和过期
// 3. 查 DB 实时校验 token_version 和 banned 状态
func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool) {
tokenStr, err := c.Cookie(service.CookieName)
if err != nil || tokenStr == "" {
- auth := c.GetHeader("Authorization")
- if auth == "" {
- return nil, false
- }
- tokenStr = strings.TrimPrefix(auth, "Bearer ")
- if tokenStr == auth {
- return nil, false
- }
+ return nil, false
}
claims, err := m.auth.ParseToken(tokenStr)
if err != nil {
diff --git a/backend/middleware/security.go b/backend/middleware/security.go
index 600ee82..aa9f0ad 100644
--- a/backend/middleware/security.go
+++ b/backend/middleware/security.go
@@ -32,9 +32,6 @@ func SecurityHeaders() gin.HandlerFunc {
// 控制 Referer 信息泄露
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
- // XSS 防护(旧浏览器兼容,现代浏览器靠 CSP)
- c.Header("X-XSS-Protection", "1; mode=block")
-
// 禁止浏览器缓存敏感页面(可按需覆盖)
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
diff --git a/backend/model/db.go b/backend/model/db.go
index 6ac4b4c..3caa021 100644
--- a/backend/model/db.go
+++ b/backend/model/db.go
@@ -45,12 +45,6 @@ func InitDB(dsn string) error {
return fmt.Errorf("自动迁移失败: %w", err)
}
- // 旧 post_type=normal → discussion
- if err := db.Exec(`UPDATE posts SET post_type = ? WHERE post_type = ? OR post_type = '' OR post_type IS NULL`,
- PostTypeDiscussion, PostTypeNormal).Error; err != nil {
- return fmt.Errorf("帖子类型归一失败: %w", err)
- }
-
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
if err := backfillPointsFromCheckin(db); err != nil {
return fmt.Errorf("积分余额回填失败: %w", err)
diff --git a/backend/model/models.go b/backend/model/models.go
index 3e9fac1..1e15712 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -54,14 +54,13 @@ const (
ContentStatusRejected = "rejected"
)
-// 帖子类型(discussion 为默认;兼容旧值 normal → discussion)
+// 帖子类型(discussion 为默认)
const (
PostTypeDiscussion = "discussion" // 讨论(默认)
PostTypeQuestion = "question" // 问答
PostTypePoll = "poll" // 投票
PostTypeBounty = "bounty" // 悬赏
PostTypeLottery = "lottery" // 抽奖
- PostTypeNormal = "normal" // 旧值,读取时归一为 discussion
)
// ValidPostType 发帖类型白名单
@@ -73,11 +72,8 @@ func ValidPostType(t string) bool {
return false
}
-// NormalizePostType 归一化类型(空/旧 normal → discussion)
+// NormalizePostType 归一化类型(空或未知 → discussion)
func NormalizePostType(t string) string {
- if t == "" || t == PostTypeNormal {
- return PostTypeDiscussion
- }
if ValidPostType(t) {
return t
}
diff --git a/backend/router/router.go b/backend/router/router.go
index 2f66c79..16a3b80 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -33,7 +33,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
- AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-CSRF-Token"},
+ AllowHeaders: []string{"Origin", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
@@ -98,10 +98,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
- // 健康检查 & SEO
+ // 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
r.GET("/health", h.Health)
- r.GET("/robots.txt", h.RobotsTxt)
- r.GET("/sitemap.xml", h.SitemapXML)
// 实时通信总线(WebSocket,cookie 鉴权 + Origin 校验,处理器内部完成鉴权升级)
r.GET("/api/ws", h.RealtimeWS)
@@ -111,6 +109,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
{
pubAPI.GET("/me", h.Me)
pubAPI.GET("/boards", h.Boards)
+ pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
pubAPI.GET("/overview", h.Overview)
pubAPI.GET("/posts", h.Posts)
pubAPI.GET("/posts/:id", h.PostDetail)
@@ -197,10 +196,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 各功能点再由 RequirePerm 按角色放行;板块范围在 service 层按 Actor.BoardIDs 隔离
staffAPI := r.Group("/api/admin", authMW.RequireStaff(), middleware.CSRFMiddleware())
{
- staffAPI.GET("/dashboard", func(c *gin.Context) {
- c.JSON(200, gin.H{"message": "admin dashboard"})
- })
-
// 内容审核队列(任意管理角色;板块管理员只见授权板块)
staffAPI.GET("/moderation/pending-posts", h.AdminPendingPosts)
staffAPI.GET("/moderation/pending-comments", h.AdminPendingComments)
diff --git a/backend/service/board.go b/backend/service/board.go
index 3215f16..6bbf8c9 100644
--- a/backend/service/board.go
+++ b/backend/service/board.go
@@ -1,6 +1,8 @@
package service
import (
+ "time"
+
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
@@ -20,6 +22,32 @@ type BoardWithCount struct {
PostCount int64 `json:"post_count"`
}
+// BoardModerator 板块管理员公开资料
+type BoardModerator struct {
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+}
+
+// BoardSidebarStats 板级统计(不含在线)
+type BoardSidebarStats struct {
+ PostCount int64 `json:"post_count"`
+ TodayPosts int64 `json:"today_posts"`
+ CommentCount int64 `json:"comment_count"`
+ TodayComments int64 `json:"today_comments"`
+}
+
+// BoardSidebarData 首页选中板块后的右栏聚合
+type BoardSidebarData struct {
+ Board BoardWithCount `json:"board"`
+ Moderators []BoardModerator `json:"moderators"`
+ Stats BoardSidebarStats `json:"stats"`
+ Hot []PostListItem `json:"hot"`
+ ActiveUsers []ActiveUser `json:"active_users"`
+ Checkin *CheckinStatus `json:"checkin,omitempty"`
+}
+
// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
func (s *BoardService) List() ([]BoardWithCount, error) {
var boards []model.Board
@@ -56,3 +84,141 @@ func (s *BoardService) Get(id uint) (*model.Board, error) {
}
return &board, nil
}
+
+// Sidebar 首页板块右栏:管理员 + 概况统计 + 本板热门/活跃
+func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
+ board, err := s.Get(boardID)
+ if err != nil {
+ return nil, err // 含 gorm.ErrRecordNotFound
+ }
+
+ data := &BoardSidebarData{
+ Board: BoardWithCount{Board: *board},
+ Moderators: []BoardModerator{},
+ Hot: []PostListItem{},
+ ActiveUsers: []ActiveUser{},
+ }
+
+ now := time.Now()
+ dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ weekStart := startOfWeek(now)
+
+ // 帖子总数 / 今日新帖
+ if err := s.db.Model(&model.Post{}).
+ Where("board_id = ? AND status = ?", boardID, model.ContentStatusPublished).
+ Count(&data.Stats.PostCount).Error; err != nil {
+ return nil, err
+ }
+ data.Board.PostCount = data.Stats.PostCount
+ if err := s.db.Model(&model.Post{}).
+ Where("board_id = ? AND status = ? AND created_at >= ?", boardID, model.ContentStatusPublished, dayStart).
+ Count(&data.Stats.TodayPosts).Error; err != nil {
+ return nil, err
+ }
+
+ // 评论总数 / 今日评论(经 posts.board_id)
+ if err := s.db.Model(&model.Comment{}).
+ Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
+ Where("posts.board_id = ? AND posts.status = ? AND comments.status = ?",
+ boardID, model.ContentStatusPublished, model.ContentStatusPublished).
+ Count(&data.Stats.CommentCount).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.Model(&model.Comment{}).
+ Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
+ Where("posts.board_id = ? AND posts.status = ? AND comments.status = ? AND comments.created_at >= ?",
+ boardID, model.ContentStatusPublished, model.ContentStatusPublished, dayStart).
+ Count(&data.Stats.TodayComments).Error; err != nil {
+ return nil, err
+ }
+
+ // 板块管理员:仅 board_admin 且授权本板、未封禁
+ var mods []model.User
+ if err := s.db.Table("users").
+ Select("users.id, users.username, users.nickname, users.avatar").
+ Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
+ Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
+ Order("users.id ASC").
+ Find(&mods).Error; err != nil {
+ return nil, err
+ }
+ for _, u := range mods {
+ data.Moderators = append(data.Moderators, BoardModerator{
+ ID: u.ID,
+ Username: u.Username,
+ Nickname: u.Nickname,
+ Avatar: u.Avatar,
+ })
+ }
+
+ // 本板热门
+ var hotPosts []model.Post
+ if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished).
+ Order("(like_count + comment_count * 2) DESC, created_at DESC").
+ Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
+ return nil, err
+ }
+ data.Hot = toPostListItems(hotPosts)
+
+ // 本板本周活跃
+ type activityRow struct {
+ UserID uint
+ PostCount int64
+ CommentCount int64
+ }
+ var rows []activityRow
+ activitySQL := `
+SELECT a.user_id AS user_id,
+ SUM(a.is_post) AS post_count,
+ SUM(1 - a.is_post) AS comment_count
+FROM (
+ SELECT user_id, 1 AS is_post FROM posts
+ WHERE status = ? AND board_id = ? AND created_at >= ? AND deleted_at IS NULL
+ UNION ALL
+ SELECT c.user_id, 0 AS is_post FROM comments c
+ JOIN posts p ON p.id = c.post_id AND p.deleted_at IS NULL
+ WHERE c.status = ? AND c.created_at >= ? AND c.deleted_at IS NULL
+ AND p.board_id = ? AND p.status = ?
+) AS a
+JOIN users AS u ON u.id = a.user_id
+WHERE u.banned = ? AND u.deleted_at IS NULL
+GROUP BY a.user_id
+ORDER BY COUNT(*) DESC, a.user_id ASC
+LIMIT 5`
+ if err := s.db.Raw(activitySQL,
+ model.ContentStatusPublished, boardID, weekStart,
+ model.ContentStatusPublished, weekStart, boardID, model.ContentStatusPublished,
+ false).Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ if len(rows) > 0 {
+ ids := make([]uint, 0, len(rows))
+ for _, r := range rows {
+ ids = append(ids, r.UserID)
+ }
+ var users []model.User
+ if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil {
+ return nil, err
+ }
+ userMap := make(map[uint]model.User, len(users))
+ for _, u := range users {
+ userMap[u.ID] = u
+ }
+ for _, r := range rows {
+ u, ok := userMap[r.UserID]
+ if !ok {
+ continue
+ }
+ data.ActiveUsers = append(data.ActiveUsers, ActiveUser{
+ ID: u.ID,
+ Username: u.Username,
+ Nickname: u.Nickname,
+ Avatar: u.Avatar,
+ PostCount: r.PostCount,
+ CommentCount: r.CommentCount,
+ })
+ }
+ }
+
+ return data, nil
+}
diff --git a/backend/service/post.go b/backend/service/post.go
index 3f987b0..ff997f6 100644
--- a/backend/service/post.go
+++ b/backend/service/post.go
@@ -29,7 +29,7 @@ type PostListQuery struct {
BoardID uint
Page int
Size int
- Sort string // latest | hot | new
+ Sort string // latest | recommended | new
Keyword string // 搜索关键词
Recommended bool // 仅精华帖
ViewerID uint // 当前查看者(0=游客)
@@ -209,7 +209,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
SELECT MAX(c.created_at) FROM comments c
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
), created_at)) DESC`
- if q.Sort == "hot" {
+ if q.Sort == "recommended" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
}
// new:纯按发帖时间(新帖子)
diff --git a/frontend/app/admin/moderation/ModerationAdmin.tsx b/frontend/app/admin/moderation/ModerationAdmin.tsx
index 25710e4..3990645 100644
--- a/frontend/app/admin/moderation/ModerationAdmin.tsx
+++ b/frontend/app/admin/moderation/ModerationAdmin.tsx
@@ -270,7 +270,7 @@ export default function ModerationAdmin({
@@ -354,7 +354,7 @@ export default function ModerationAdmin({
{c.user.nickname}
diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx
deleted file mode 100644
index a55c9af..0000000
--- a/frontend/app/board/[id]/page.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import Link from "next/link";
-import type { Metadata } from "next";
-import { cookies } from "next/headers";
-import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react";
-import { fetchPosts, fetchBoards } from "@/lib/api";
-import { authCookieHeader } from "@/lib/cookies";
-import { getMeCached } from "@/lib/serverData";
-import Pagination from "@/components/Pagination";
-import PostRow from "@/components/PostRow";
-import PinnedPosts from "@/components/PinnedPosts";
-import FeedNewTip from "@/components/home/FeedNewTip";
-
-interface PageProps {
- params: Promise<{ id: string }>;
- searchParams: Promise<{ page?: string }>;
-}
-
-export async function generateMetadata({ params }: PageProps): Promise
{
- const { id } = await params;
- const { boards } = await fetchBoards();
- const board = boards.find((b) => b.id === Number(id));
- return {
- title: board ? `${board.name} · 姜十三论坛` : "板块 · 姜十三论坛",
- description: board?.description || "板块帖子列表",
- };
-}
-
-export default async function BoardPage({ params, searchParams }: PageProps) {
- const { id } = await params;
- const { page: pageStr } = await searchParams;
- const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
- const cookie = authCookieHeader(await cookies());
- const [postsData, boardsData, me] = await Promise.all([
- fetchPosts(page, 20, Number(id), "latest", "", false, cookie),
- fetchBoards(),
- getMeCached(cookie || undefined),
- ]);
- const boardIndex = boardsData.boards.findIndex((b) => b.id === Number(id));
- const board = boardIndex >= 0 ? boardsData.boards[boardIndex] : null;
- const user = me.user;
-
- // 第一页:置顶帖独立成区,列表只保留非置顶
- const pinned = page === 1 ? postsData.posts.filter((p) => p.pinned > 0) : [];
- const rows = page === 1 ? postsData.posts.filter((p) => p.pinned <= 0) : postsData.posts;
-
- return (
-
- {/* 面包屑 */}
-
-
-
-
-
-
-
- {board?.name.slice(0, 1) || "?"}
-
-
-
- {board?.name || "未知板块"}
-
- {board?.description && (
-
- {board.description}
-
- )}
-
{postsData.total} 篇讨论
-
-
-
-
在此板块发帖
-
-
-
-
-
-
-
- {rows.length > 0 ? (
-
-
- 全部讨论
- 第 {page} 页
-
- {user && (
-
-
-
- )}
- {rows.map((post, i) => (
-
- ))}
-
- ) : (
- pinned.length === 0 && (
-
-
-
-
-
该板块还没有帖子
-
来发第一篇,抢占沙发
-
-
写新帖
-
-
- )
- )}
-
-
-
-
-
-
- );
-}
diff --git a/frontend/app/boards/page.tsx b/frontend/app/boards/page.tsx
deleted file mode 100644
index cb4e79a..0000000
--- a/frontend/app/boards/page.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import Link from "next/link";
-import type { Metadata } from "next";
-import { ChevronRight, MessagesSquare, LayoutGrid } from "lucide-react";
-import { fetchBoards } from "@/lib/api";
-
-export const metadata: Metadata = {
- title: "板块目录 · 姜十三论坛",
- description: "浏览论坛全部板块,找到感兴趣的话题社区",
-};
-
-// 板块汇总列表页:首页左栏「板块目录」标题的落地页,
-// 展示全部板块(介绍 + 讨论量),点击进入板块独立页
-export default async function BoardsPage() {
- const { boards } = await fetchBoards();
- const totalPosts = boards.reduce((sum, b) => sum + (b.post_count || 0), 0);
-
- return (
-
- {/* 面包屑 */}
-
-
- {/* 页头 */}
-
-
-
-
-
-
-
-
- 板块目录
-
-
- 共 {boards.length} 个板块 · {totalPosts} 篇讨论
-
-
-
-
-
- {/* 板块卡片网格 */}
- {boards.length === 0 ? (
-
-
-
-
-
还没有任何板块
-
板块由管理员创建,敬请期待
-
- ) : (
-
- {boards.map((b, i) => (
-
-
- {b.name.slice(0, 1)}
-
-
-
-
- {b.name}
-
- {b.post_count} 篇
-
- {b.description?.trim() && (
-
- {b.description.trim()}
-
- )}
-
- 进入板块
-
-
-
-
- ))}
-
- )}
-
- );
-}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index cdcf98a..d10ee25 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -11,7 +11,7 @@ import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
-import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
+import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
import { accentStyleCSS } from "@/lib/theme";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
@@ -59,11 +59,9 @@ export default async function RootLayout({
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
- // SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts),
+ // SSR 直出登录态(access 过期时 middleware 已先行轮转并注入请求头,见 middleware.ts),
// 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"。
- // 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
- // token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
- // Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
+ // 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
const { accent } = await getPublicSettingsCached();
@@ -72,8 +70,7 @@ export default async function RootLayout({
let user: User | null = null;
let unread = 0;
let chatUnread = 0;
- const hasAuthCookie =
- !!cookieStore.get(TOKEN_COOKIE)?.value || !!cookieStore.get(REFRESH_COOKIE)?.value;
+ const hasAuthCookie = !!cookieStore.get(TOKEN_COOKIE)?.value;
if (hasAuthCookie) {
// 用户信息与未读数合并为一次请求;只转发最小化的 access cookie
const me = await getMeCached(authCookieHeader(cookieStore) || undefined);
diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx
index f6fc4df..5cfec10 100644
--- a/frontend/app/not-found.tsx
+++ b/frontend/app/not-found.tsx
@@ -23,8 +23,8 @@ export default function NotFound() {
返回首页
-
- 逛逛板块
+
+ 逛逛讨论
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index b6d9085..64b09be 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -5,7 +5,9 @@ import { PenSquare, SearchX, MessagesSquare } from "lucide-react";
import {
fetchPosts,
fetchOverview,
+ fetchBoardSidebar,
type OverviewResponse,
+ type BoardSidebarResponse,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
@@ -15,6 +17,11 @@ import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip";
import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
+import {
+ BoardPanels,
+ BoardPanelsMobile,
+ BoardMissingPanel,
+} from "@/components/home/BoardPanels";
export const metadata: Metadata = {
title: "姜十三论坛 · 技术讨论社区",
@@ -27,8 +34,6 @@ interface HomePageProps {
function normalizeSort(v?: string): FeedSort {
if (v === "new" || v === "recommended") return v;
- // 兼容旧的 ?sort=hot 链接:落到最接近的推荐帖
- if (v === "hot") return "recommended";
return "latest";
}
@@ -81,22 +86,30 @@ export default async function HomePage({ searchParams }: HomePageProps) {
page,
20,
isSearch || boardId <= 0 ? undefined : boardId,
- sort === "recommended" ? "hot" : sort,
+ sort,
keyword,
sort === "recommended",
cookie
);
// 聚合面板失败不阻塞帖子流
const overviewPromise: Promise = fetchOverview(cookie).catch(() => null);
+ const boardSidebarPromise: Promise =
+ boardId > 0 && !isSearch
+ ? fetchBoardSidebar(boardId, cookie).catch(() => null)
+ : Promise.resolve(null);
- const [me, postsData, overview] = await Promise.all([
+ const [me, postsData, overview, boardSidebar] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
+ boardSidebarPromise,
]);
const user = me.user;
const boards = overview?.boards ?? [];
- const activeBoard = boards.find((b) => b.id === boardId) || null;
+ const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
+ const showBoardPanels = boardId > 0 && !isSearch;
+ // boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
+ const boardPanelsMissing = showBoardPanels && !boardSidebar;
// 搜索态:单栏占满,无 Tabs/侧栏
if (isSearch) {
@@ -193,31 +206,61 @@ export default async function HomePage({ searchParams }: HomePageProps) {
/>
- {/* 右栏:社区面板 */}
- {overview && (
-
+ {/* 右栏:选中板块时切板级面板,否则全站社区面板 */}
+ {showBoardPanels ? (
+ boardSidebar ? (
+
+ ) : boardPanelsMissing ? (
+
+ ) : null
+ ) : (
+ overview && (
+
+ )
)}
{/* 窄屏:面板下沉到帖子流之后 */}
- {overview && (
-
-
-
+ {showBoardPanels ? (
+ boardSidebar ? (
+
+
+
+ ) : null
+ ) : (
+ overview && (
+
+
+
+ )
)}
);
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 48fcccf..9e37117 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -121,7 +121,7 @@ function BoardCard({ board }: { board: Board }) {
所属板块
-
+
@@ -140,10 +140,10 @@ function BoardCard({ board }: { board: Board }) {
)}
- 进入板块
+ 查看该板讨论
);
@@ -266,7 +266,7 @@ function RelatedPosts({
))}
@@ -334,7 +334,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
fetchComments(id, commentPage, cookie),
getMeCached(cookie || undefined),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
- fetchPosts(1, 8, post.board_id, "hot", "", false, cookie).catch(() => null),
+ fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
]);
const comments = commentsData.comments;
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
@@ -424,7 +424,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
@@ -432,7 +432,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
-
+
{post.board.name}
diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts
index 19905a9..aebf7bf 100644
--- a/frontend/app/sitemap.ts
+++ b/frontend/app/sitemap.ts
@@ -1,5 +1,5 @@
import type { MetadataRoute } from "next";
-import { fetchPosts, fetchBoards } from "@/lib/api";
+import { fetchPosts } from "@/lib/api";
export default async function sitemap(): Promise {
const baseUrl = "http://localhost:3000";
@@ -8,16 +8,6 @@ export default async function sitemap(): Promise {
];
try {
- const { boards } = await fetchBoards();
- for (const board of boards) {
- entries.push({
- url: `${baseUrl}/board/${board.id}`,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 0.8,
- });
- }
-
const { posts } = await fetchPosts(1, 100);
for (const post of posts) {
entries.push({
diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx
index 08de8fe..ce4e543 100644
--- a/frontend/components/Header.tsx
+++ b/frontend/components/Header.tsx
@@ -20,7 +20,6 @@ import {
ChevronDown,
ShieldCheck,
MessageSquare,
- LayoutGrid,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
@@ -87,8 +86,8 @@ export default function Header({
});
}), [router]);
- // 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用、旧 Path refresh
- // 半登录等边缘情况。正常 F5 下结果与 SSR 一致,不产生可见闪动;
+ // 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用等边缘情况。
+ // 正常 F5 下结果与 SSR 一致,不产生可见闪动;
// 无可读 CSRF cookie 说明必然是游客,跳过请求
useEffect(() => {
if (!hasAuthCookieHint()) return;
@@ -238,13 +237,6 @@ export default function Header({
}>
首页
- }
- >
- 板块
-
@@ -378,12 +370,6 @@ export default function Header({
首页
-
- 板块
-
{user &&
发帖}
{user && (
diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx
index 05d8852..0930854 100644
--- a/frontend/components/PostRow.tsx
+++ b/frontend/components/PostRow.tsx
@@ -91,7 +91,7 @@ export default function PostRow({
}`}
>
{boardHref && (
-
+
{post.board.name}
)}
diff --git a/frontend/components/ThemeSync.tsx b/frontend/components/ThemeSync.tsx
index 4498162..ce23417 100644
--- a/frontend/components/ThemeSync.tsx
+++ b/frontend/components/ThemeSync.tsx
@@ -26,7 +26,7 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
useEffect(() => {
// 外观设置页存在"未保存的本地实时预览",该页面内暂停同步,
- // 避免轮询结果覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
+ // 避免 HTTP 校准覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
if (pathname?.startsWith("/admin/appearance")) return;
let cancelled = false;
diff --git a/frontend/components/home/BoardPanels.tsx b/frontend/components/home/BoardPanels.tsx
new file mode 100644
index 0000000..2637e10
--- /dev/null
+++ b/frontend/components/home/BoardPanels.tsx
@@ -0,0 +1,307 @@
+import Link from "next/link";
+import {
+ Flame,
+ Users,
+ MessageCircle,
+ Heart,
+ Shield,
+ LayoutGrid,
+ PenLine,
+ PenSquare,
+ BarChart3,
+ TrendingUp,
+} from "lucide-react";
+import type {
+ PostListItem,
+ ActiveUser,
+ BoardWithCount,
+ BoardModerator,
+ BoardSidebarStats,
+ CheckinStatus,
+} from "@/lib/api";
+import Avatar from "@/components/Avatar";
+import { PulseCheckin } from "./CheckinCard";
+import { formatCompact } from "@/lib/format";
+
+/* ---------- 1. 板块管理员 ---------- */
+
+function Moderators({ items }: { items: BoardModerator[] }) {
+ return (
+
+
+ 板块管理员
+
+ {items.length === 0 ? (
+ 暂无板块管理员
+ ) : (
+
+ {items.map((u) => {
+ const name = u.nickname || u.username;
+ return (
+ -
+
+
+
+ {name}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/* ---------- 2. 板块概况 ---------- */
+
+function BoardIntro({ board }: { board: BoardWithCount }) {
+ return (
+
+
+ 板块概况
+
+
+ {board.name}
+
+ {board.description ? (
+ {board.description}
+ ) : (
+ 暂无板块介绍
+ )}
+ 讨论 {formatCompact(board.post_count)} 篇
+
+
+ );
+}
+
+/* ---------- 3. 板块数据 + 签到 ---------- */
+
+function BoardPulse({
+ stats,
+ checkin,
+}: {
+ stats: BoardSidebarStats;
+ checkin?: CheckinStatus | null;
+}) {
+ const cells = [
+ {
+ icon: ,
+ label: "话题总数",
+ value: formatCompact(stats.post_count),
+ foot: `+${formatCompact(stats.today_posts)} 今日`,
+ up: true,
+ },
+ {
+ icon: ,
+ label: "今日新帖",
+ value: formatCompact(stats.today_posts),
+ foot: "本日新增",
+ up: stats.today_posts > 0,
+ },
+ {
+ icon: ,
+ label: "回复总数",
+ value: formatCompact(stats.comment_count),
+ foot: `+${formatCompact(stats.today_comments)} 今日`,
+ up: true,
+ },
+ {
+ icon: ,
+ label: "今日回复",
+ value: formatCompact(stats.today_comments),
+ foot: "本日新增",
+ up: stats.today_comments > 0,
+ },
+ ];
+ return (
+
+
+ 板块数据
+
+
+ {cells.map((c) => (
+
+
+
+ {c.icon}
+
+ {c.label}
+
+
{c.value}
+
+ {c.up && }
+ {c.foot}
+
+
+ ))}
+
+
+
+ );
+}
+
+/* ---------- 4. 本板热门 ---------- */
+
+function BoardHot({ posts }: { posts: PostListItem[] }) {
+ return (
+
+
+ 本板热门
+
+ {posts.length === 0 ? (
+ 本板还没有热门讨论
+ ) : (
+
+ {posts.map((p, i) => (
+ -
+
+ {i + 1}
+
+
+ {p.title}
+
+
+
+ {p.comment_count}
+
+
+ {p.like_count}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* ---------- 5. 本板活跃 ---------- */
+
+function BoardActive({ users }: { users: ActiveUser[] }) {
+ return (
+
+
+ 本板活跃
+
+ 本周
+
+
+ {users.length === 0 ? (
+ 本周本板还没有人互动
+ ) : (
+
+ {users.map((u) => {
+ const name = u.nickname || u.username;
+ return (
+ -
+
+
+
+
+ {name}
+
+
+
+ 发帖 {u.post_count}
+
+
+ 回复 {u.comment_count}
+
+
+
+
+ {u.post_count + u.comment_count}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/* ---------- 装配 ---------- */
+
+type BoardPanelsProps = {
+ board: BoardWithCount;
+ moderators: BoardModerator[];
+ stats: BoardSidebarStats;
+ hot: PostListItem[];
+ activeUsers: ActiveUser[];
+ checkin?: CheckinStatus | null;
+};
+
+function BoardPanelsBody(props: BoardPanelsProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+export function BoardPanels(props: BoardPanelsProps) {
+ return (
+
+ );
+}
+
+export function BoardPanelsMobile(props: BoardPanelsProps) {
+ return (
+
+
+
+ );
+}
+
+/** 板块不存在时的简卡 */
+export function BoardMissingPanel() {
+ return (
+
+ );
+}
diff --git a/frontend/components/home/WorkspaceNav.tsx b/frontend/components/home/WorkspaceNav.tsx
index 669cfd6..2bf4d34 100644
--- a/frontend/components/home/WorkspaceNav.tsx
+++ b/frontend/components/home/WorkspaceNav.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
-import { PenSquare, Home, ChevronRight } from "lucide-react";
+import { PenSquare, Home } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -34,26 +34,14 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
- {/* 板块目录:标题可点击进入板块汇总列表页(/boards);
- 下方板块卡片点击不离开工作台,仅在中栏筛选该板块帖子流 */}
+ {/* 板块目录:纯分区标题;下方卡片仅在中栏筛选该板帖子流 */}
-
-
- 板块目录
-
-
+
+ 板块目录
{boards.length > 0 && (
{boards.length} 个
)}
-
+
{boards.map((b, i) => (
{
+ const res = await fetch(
+ `${API_BASE}/api/boards/${boardId}/sidebar`,
+ ssrInit(cookieHeaders(cookieHeader))
+ );
+ if (!res.ok) throw new Error("获取板块侧栏失败");
+ return res.json();
+}
+
export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
if (!res.ok) throw new Error("获取板块失败");
@@ -1375,7 +1414,7 @@ export async function apiAdminUserAuditMessages(
// ===== 站点外观设置 =====
export async function apiGetSettings(): Promise
{
- // no-store:主题同步轮询必须读到管理员刚保存的最新配色,不走浏览器缓存
+ // no-store:主题同步必须读到管理员刚保存的最新配色,不走浏览器缓存
const res = await fetchWithRefresh("/api/settings", { method: "GET", cache: "no-store" });
if (!res.ok) throw new Error("获取站点设置失败");
return res.json();
diff --git a/frontend/lib/postMeta.ts b/frontend/lib/postMeta.ts
index 3f6c24d..441cd25 100644
--- a/frontend/lib/postMeta.ts
+++ b/frontend/lib/postMeta.ts
@@ -23,7 +23,7 @@ export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
export function normalizePostType(t?: string): PostTypeValue {
- if (t === "normal" || !t) return "discussion";
+ if (!t) return "discussion";
if (POST_TYPES.some((x) => x.value === t)) return t as PostTypeValue;
return "discussion";
}
diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts
index 2466508..12b4e71 100644
--- a/frontend/lib/realtime.ts
+++ b/frontend/lib/realtime.ts
@@ -7,7 +7,7 @@
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
-// - 轮询仅作兜底:回前台 / 进页时 HTTP 校准;实时靠 WS(通知、待审、主题、群聊未读)。
+// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
export const RT_HELLO = "hello";
export const RT_PONG = "pong";