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} - - {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.label} +

+

{c.value}

+

+ {c.up &&

+
+ ))} +
+
+ +
+
+ ); +} + +/* ---------- 4. 本板热门 ---------- */ + +function BoardHot({ posts }: { posts: PostListItem[] }) { + return ( +
+

+ 本板热门 +

+ {posts.length === 0 ? ( +

本板还没有热门讨论

+ ) : ( +
    + {posts.map((p, i) => ( +
  1. + + {i + 1} + + + {p.title} + + + + {p.comment_count} + + + {p.like_count} + + + + +
  2. + ))} +
+ )} +
+ ); +} + +/* ---------- 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";