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({
- {board.description} -
- )} -{postsData.total} 篇讨论
-该板块还没有帖子
-来发第一篇,抢占沙发
- -- 共 {boards.length} 个板块 · {totalPosts} 篇讨论 -
-还没有任何板块
-板块由管理员创建,敬请期待
-
+
暂无板块管理员
+ ) : ( +
+
+ {board.name} +
+ {board.description ? ( +{board.description}
+ ) : ( +暂无板块介绍
+ )} +讨论 {formatCompact(board.post_count)} 篇
+
+
+ + {c.label} +
+{c.value}
++ {c.up && } + {c.foot} +
+
+
本板还没有热门讨论
+ ) : ( +
+
本周本板还没有人互动
+ ) : ( +