Files
jiang13-bbs/.cursor/project_memory.md

37 KiB
Raw Blame History

jiang13-bbs 论坛项目记忆

来源:Trae CN ~/.trae-cn/memory/.../project_memory.md,于 2026-09-15 导入 Cursor(.cursor/)。 配套:user_preferences.md、skills/theme-recolor-workflow/、以及 rules/jiang13-bbs-*.mdc(alwaysApply)。

记忆初始化指令

以下为项目核心规则,所有后续开发必须遵守:

  1. 项目使用 Go 1.27 + Next.js 16,禁止生成旧版本惯用代码
  2. Next.js 16 的 params 等必须 await,这是最高频错误,每次生成后自查
  3. 如果部署环境涉及 Cloudflare Workers,middleware.ts 不能改名为 proxy.ts
  4. 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
  5. 权限逻辑只在 Go 端,前端不做权限判定
  6. 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
  7. 用户未明确告知「已正式部署到生产环境」之前:禁止为旧版本/旧契约写兼容冗余(双写双读、fallback、过渡字段、旧 cookie/旧 API 垫片);直接改到位并删旧代码,保持源码干净规范

技术栈

  • Go 1.27 + Gin
  • Next.js 16 (App Router) + TypeScript + Tailwind CSS
  • PostgreSQL

官方运行时(用户 2026-09-18 冻结,跨会话必须延续,未开工)

管理员可在后台一键更新 不做本期。先做「官方运行时」:一种可替换的交付形态。运行时契约冻住后很少改;论坛功能走新镜像 / 新包。用户主路径是 Docker pull + up。有空再按下列阶段开发,未轮到的阶段不要提前做。

已对齐的产品结论

  • 两层分开:运行时 = Dockerfile / Compose / volume / 环境变量;应用 = Go + Next。反代不进官方 Compose,由部署方自备。用户日常更新应用,不重装运行时。
  • 官方运行时 = Docker Compose 一体交付(web + api + Postgres)。预编译包是同一目录布局的第二种皮,排在 Docker 之后。
  • 真 SSR 不能扔:Next.js 16 必须有 Node 进程,禁止 output:'export' 塞进 Go 当静态站。
  • 必须同一 Host:页面、/api/、/api/ws、/uploads 同域;__Host- cookie 与 WS Origin 不允许 API 另开子域。
  • 数据与程序分离:Postgres volume、data/(上传与 JWT 密钥)、配置(env / 挂载的 app.ini)一律不进镜像。
  • 本地开发不动:仓库根现有 docker-compose.yml 只起 Postgres,配合 start.bat / cd backend && go run ./cmd/jiang13 + frontend npm run dev。官方运行时用另一份 Compose(如 deploy/docker-compose.yml),禁止把开发流程改成「本地也必须打应用镜像」。
  • 更新方式:Docker 用户 docker compose pull && docker compose up -d;启动时既有 AutoMigrate 即可。接受短暂停机,上线前仍禁止为双版本并存写兼容垫片。
  • 明确不做:源码机上 git pull + go build / next build 当官方路径;把 Docker socket 交给 Web 进程;Cloudflare Workers 拆分部署当官方运行时(可当高级可选项,不走一键更新);后台一键替换进程(等运行时契约冻住且确有非 Docker 用户再单独立项)。

目标拓扑(官方 Compose)

浏览器 ──同 Host──► 宿主机反代(Nginx/Caddy,自备)
                      ├─ 页面 / RSC     → 127.0.0.1:3000(web)
                      ├─ /api /api/ws   → 127.0.0.1:3001(api)
                      └─ /uploads       → 127.0.0.1:3001(api)
Postgres volume ──► postgres:17
data volume     ──► Go 的 dataDir(与本地 backend/data 语义一致)
  • Go 工作目录 / JIANG13_WORK_PATH 对准容器内数据根,启动日志须含 workPath + dataDir。
  • DEV_MODE=false;JWT_SECRET、DSN、站点 URL 走环境变量,镜像不含真实 app.ini。
  • 健康检查:Go /health 与 Next /healthz;反代须透传 Upgrade / Connection,保留 Host / Origin。
  • Compose 不跑 Nginx/Caddy。示例配置:deploy/nginx.example.conf。

落地进度(2026-09-18)

阶段 1–3 已落地:deploy/ 为 web + api + postgres,不含反代;宿主机 Nginx 示例见 deploy/nginx.example.conf。仓库根 docker-compose.yml / start.bat 未改。阶段 4–6 仍未做。

开发阶段(按序,未点名不做)

阶段 1 — 版本与契约(开工第一步)

  • 前后端同一 semver:Go ldflags 注入;前端构建期写入(勿再用页脚空占位当正式版本)。
  • 写死运行时契约:服务名、端口、volume 名、环境变量表、数据目录含义。契约变更视为运行时大版本,须 changelog。
  • Release 产物:带 tag 的镜像 + 校验和;changelog;最低可升级版本(跨大版本禁止跳迁)。

阶段 2 — 官方 Docker 运行时(主交付)

  • 多阶段 Dockerfile:编 Go 二进制、编 Next standalone,运行镜像不含源码与工具链。
  • deploy/docker-compose.yml:web + api + postgres。反代不进 Compose,由部署方用 Nginx 等同源反代。
  • volume:pgdata、appdata(映射到 Go dataDir)。配置只挂 env / 可选 app.ini。
  • 首次 up 即迁移 + 可登录;文档只写 Compose 步骤,不要求宿主机装 Go/Node。
  • 回归:同 Host cookie、WS 握手、头像 /uploads、重启容器数据还在。

阶段 3 — 文档化更新(Docker 用户的官方升级)

  • 标准命令:docker compose pull && docker compose up -d。
  • 说明:短暂停机、备份(pg dump + appdata)、失败则改回上一 tag。
  • Compose 文件本身尽量向前兼容(新 env 有默认);真要改文件时 changelog 写「请合并 compose」,与「只 pull 应用镜像」分开。

阶段 4 — 预编译包(可选,布局与镜像内一致)

  • 包内:jiang13(或 .exe)、Next standalone、示例配置、watchdog/supervisor。
  • Windows 不能覆盖正在运行的 exe,必须靠外层 watchdog 换进程;Linux 可 exec 或 systemd。
  • 与 Docker 共用版本号与数据目录语义;更新仍是换文件 + 重启,不是本机编译。

阶段 5 — 后台「检查更新」(只读,非一键)

  • 仅 owner:当前版本、频道(稳定/测试)、changelog、最低可升级版本。
  • Docker 用户展示官方 pull && up 说明;不支持的部署方式如实说「请按文档升级」。
  • 不做下载、不做替换、不暴露 Docker socket。

阶段 6 — 面板一键更新(明确延后,勿提前做)

  • 仅官方 Docker(updater sidecar,禁止 app 挂 docker.sock)或官方 watchdog 包。
  • 站长 + CSRF + 验签 + 审计;先备份再切;健康检查失败回滚。
  • 源码部署 / Workers / 用户自管 systemd:永远只显示「当前部署不支持在线更新」。

开工时注意

  • 先改契约与 deploy/,不要改 jiang13-bbs-local-dev 的 cwd / 端口铁律。
  • 镜像构建在 CI 或维护者机器;不要把 node_modules、.next、backend/data、真实 app.ini 打进 git 或镜像。
  • 生产 DEV_MODE=false;反代必须同源。Workers 前端不是官方运行时验收范围。

Next.js 16 关键约束

  • params/searchParams/cookies()/headers()/draftMode() 必须 await
  • 部署到 Cloudflare Workers 时保持 middleware.ts 不变(OpenNext 不识别 proxy.ts)
  • 所有 @folder 必须有 default.tsx
  • 使用 Metadata API,禁止 next/head
  • 交互组件必须标记 'use client'
  • next/image 默认 TTL 4 小时,需要时在 next.config.ts 覆盖 minimumCacheTTL
  • Pure SSR website: skeleton screens should not be used; F5 hard refresh should directly display SSR content without skeleton screen flickering (app/loading.tsx and Skeleton components have been removed)

Go 1.27 关键约束

  • 优先使用泛型方法替代重复方法定义
  • 使用 encoding/json/v2 而非旧版 json
  • 使用标准库 uuid 而非第三方库
  • 结构体字面量支持嵌入字段直接初始化

SEO 规则

  • 所有公开页面 SSR/ISR
  • SEO 关键内容(title、description、OG、JSON-LD)必须在初始 HTML 中
  • 帖子详情页输出 DiscussionForumPosting 类型的 JSON-LD
  • sitemap.ts 和 robots.ts 必须存在

权限规则

  • 权限校验在 Go 后端执行,前端不参与判定
  • 所有受保护 API 必须经过 RBAC 中间件
  • 速率限制:登录 20/分钟,注册 10/分钟,发帖新用户 24h 冷静期
  • 帖子编辑/删除权限:作者本人或管理员可执行,其他用户返回 403,未登录返回 401
  • 置顶/推荐操作仅管理员可执行,非管理员返回 403

四级 RBAC + 内容审核(2026-09)

  • 角色:owner(全站唯一,不可改角色/封禁;独占彻底删除) > super_admin(全站后台+群聊全站监管,仅 owner 可授予) > admin(公告+全站帖/评论审核软删+置顶加精) > board_admin(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > user。RoleLevel 0/30/50/80/100,staff=board_admin 及以上
  • Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids+can_manage_messages);RequirePerm(announcements/users/settings/messages);板块隔离按 BoardIDs;板管仪表盘 analytics 按授权板过滤
  • 消息:站长可授 can_manage_messages(全站群聊后台,无私聊);站长可在群内任命 ChatRoleAdmin;站长/超管 CanOverseeChat 含私聊;禁言 API 已接
  • 彻底删除帖/评:仅 owner;其余管理员仅软删+恢复
  • 发帖/评论:staff 直发 published,普通用户进 pending;pending 不计 comment_count(ApproveComment 时 +1,删除只减 published);公开列表仅 published;作者/staff 可经详情页看 pending/rejected(GetByIDForViewer + EnsurePostVisible)
  • 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400、ErrAuthorProtected/ErrPurgeOwnerOnly→403
  • 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列
  • 用户安全设置「最近登录设备」:GET /api/me/login-devices(RequireAuth,仅本人仍有效的 refresh 会话,按归一化 IP+UA 指纹去重);DELETE /api/me/login-devices/:id 剔除其它设备(吊销该指纹下除当前会话外的 refresh,JWT fid 使 access 立即失效,需重新输入密码)。展示设备/浏览器与原始 IP(不使用 ip2region);本机标 current=当前在线,其余有效会话标在线。SSR 转发 refresh cookie + 浏览器 UA/IP,直出 /u/:id?tab=security
  • 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400
  • 前端:lib/roles.ts(canModerateAuthor/canPurgeContent/canAccessAdminMessages 等);RoleBadge;Forbidden.tsx;用户管理 /admin/users
  • 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组

不使用 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=/(供 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 Bearer)
  • ValidateClaims 每请求查 DB:校验 token_version 匹配 + banned 状态(不依赖 JWT 缓存值)
  • CSRFMiddleware 对非 GET 方法校验 X-CSRF-Token header 与 j13_csrf cookie 一致
  • 需登录 API 组挂载顺序:RequireAuth → CSRFMiddleware(未登录返回 401 而非 403)
  • RefreshToken 表存储 refresh token 的 SHA-256 哈希,RotateRefreshToken 每次刷新撤销旧 token 签发新的(防重放),并设置宽限期内重放返回同一新 token
  • 检测到已撤销 refresh token 重放时,立即 RevokeAllUserRefreshTokens 吊销该用户所有 refresh token
  • /api/auth/refresh 端点验证 refresh cookie 后轮转签发新 access + refresh + csrf
  • 登出递增 User.TokenVersion 使 access token 立即失效,同时撤销该用户所有 refresh token + 清除三 cookie
  • SecurityHeaders 中间件全局添加:CSP、HSTS、X-Frame-Options: DENY、X-Content-Type-Options: nosniff、Referrer-Policy

前端(lib/api.ts):

  • 客户端请求使用相对路径 /api/*(走 Next.js rewrite 代理,浏览器视为同源)
  • 所有 fetch 带 credentials: 'include'
  • 状态变更请求(POST)自动从 document.cookie 读取 j13_csrf 并加入 X-CSRF-Token header
  • fetchWithRefresh:遇到 401 自动调用 /api/auth/refresh 续期后重试(refresh cookie 浏览器自动携带)
  • 登录态通过 GET /api/me(OptionalAuth)获取,前端不存储任何 token
  • SSR 端公开接口(fetchPosts 等)仍直连 API_BASE,无需 cookie

SSR 登录态直出(2026-09 起,消除 F5 用户区闪动):

  • frontend/middleware.ts:页面/RSC 请求前本地解码 j13_token 的 exp(不验签),过期且有 j13_refresh 时调后端 refresh 轮转,新 cookie 注入请求头供本次 SSR + 原样透传 Set-Cookie 给浏览器;同一 refresh token 有 10s 模块级去重(防 RSC 预取与导航并发导致重复轮转被判重用清登录态),仅缓存成功结果且定期清理过期条目;matcher 排除 /api/、_next/、静态资源;dev 环境保留 http://localhost:3001 兜底,生产构建强制要求显式配置 BACKEND_URL/NEXT_PUBLIC_API_URL
  • app/layout.tsx 用 cookies() 调 fetchMe/fetchUnreadCount(lib/api.ts 的 SSR 函数,转发 cookie 直连 API_BASE,设置超时兜底),把 initialUser/initialUnread/initialTheme 传给 Header;Header 用 initial 值初始化 state,不再 useEffect 调 apiMe;props 随 router.refresh() 校正
  • 登录页 push 后必须 router.refresh()(root layout 在客户端导航中持久化);改密成功同理
  • NotificationBell 红点也由 initialUnread SSR 直出,挂载后靠 WS notification:new + visibilitychange HTTP 校准(已取消 30s 定时轮询)
  • 字体用 next/font/google 构建期自托管(Plus Jakarta Sans / Noto Sans SC / JetBrains Mono,CSS 变量 --font-jakarta/--font-noto-sc/--font-jetbrains),禁止再引入 fonts.googleapis.com CDN link(会 FOUT"由粗变细"闪动)

WebSocket 实时总线(2026-09 一期)

群聊前置基建:全站一条 WS 复用所有实时事件;角标/主题以推送为主,HTTP 仅作 visibility/focus 校准(无定时轮询)。

实时化分期路线(用户 2026-09-15 冻结,跨会话必须延续,勿遗忘):

  • 一期(已完成 2026-09-15):WS 总线 + 在线状态推送 + 管理员设置变更推送(本节详见)

  • 二期(已完成 2026-09-15):群聊 —— 建群 / 成员管理 / 消息持久化 / 未读数 / @提醒 / 历史分页。铁律:消息先落库再经 WS 广播,WS 不承担可靠投递;离线未读靠 HTTP 对账。详见下方"群聊二期"节

  • 三期(已完成 2026-09-15):帖子流「有新内容」提示 —— feed:changed 全员广播(staff 直发/审核通过帖评),首页与板块页 tip,点击 router.refresh();不做自动插行/游客轮询

  • 端点 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 仍走旧口径)

  • 封禁/改角色使 token_version 不符后,WS 在下次心跳(前端 25s ping,≤25s)收到 4401;前端收到 4401 不重连,交给 HTTP 拦截/封禁模态

  • 前端 lib/realtime.ts 单例 realtime:状态机 idle/connecting/open/closed,指数退避 1→15s ±20% 抖动,window online/visibilitychange 触发重连,25s 心跳;on/off 订阅、onPresence、getOnlineIds 快照;RealtimeProvider(根布局 body 末尾,按 userId connect/disconnect,null 不连)

  • dev 直连 ws://hostname:3001/api/ws(Next dev rewrite 不透传 WS Upgrade;cookie 不按端口隔离所以同源 cookie 自动带);NEXT_PUBLIC_WS_URL 可覆盖;生产同源 /api/ws,Nginx 需配 Upgrade/Connection 头;Cloudflare Workers 不能透传任意 WS

  • 已接入:ThemeSync 订阅 settings:changed 无刷新热换肤(推送优先;已取消 60s 定时轮询,仅 visibility/focus + pathname 变化时 HTTP 校准;/admin/appearance 下整个 effect 暂停);AdminNav 待审角标订阅 moderation:changed(staff 房间)后 HTTP 校准(已取消 30s 定时轮询);/admin/users 在线点实时跳变

  • 主题换肤 DOM 铁律(2026-09-15):SSR <style id="j13-accent-vars"> 由 React 托管,客户端 禁止 对其 remove()/改写(否则 React reconcile 报 removeChild);热换肤只操作独立节点 #j13-accent-runtime(挂 body 末尾压过 SSR)。恢复默认色时:若 SSR 仍有自定义内容则 runtime 写入默认令牌覆盖,否则卸掉 runtime

  • 实测(2026-09-15):未握手 401、hello/pong/心跳、settings 双端推送热换肤与恢复默认、上下线 presence、封禁后 4401 踢出;通知/待审角标改推送后日志不再刷 30s HTTP


帖子流「有新内容」三期(2026-09-15 完成)

  • 事件 feed:changed:{kind:"post"|"comment", post_id, board_id, actor_id},BroadcastAll
  • 触发:CreatePost/CreateComment 仅 published;ApprovePost/ApproveComment 成功后广播(reject 不推)
  • 前端 FeedNewTip:首页(非搜索)与 /board/[id];page=1;sort=new 仅 post;latest 接受 post+comment;recommended 忽略;actor_id===userId 忽略;点击 router.refresh() + 滚顶
  • 不做自动插行、不做游客轮询

群聊二期(2026-09-15 完成)

模型(model/models.go):

  • ChatRoom:id/name/description/owner_id/member_count/last_message_id + soft delete;Owner User(值类型 FK)、LastMessage *ChatMessage(指针 FK,必须 constraint:- 防止 GORM 建 DB 级 FK 约束——last_message_id 默认 0 而 chat_messages 无 id=0 行)
  • ChatRoomMember:room_id+user_id 联合唯一索引、role(owner/member)、last_read_message_id(未读水位)、muted;User User 值类型 FK
  • ChatMessage:room_id+created_at 复合索引、sender_id、content(varchar 2000)、mention_ids(逗号分隔 user_id,落通知用) + soft delete;Sender User 值类型 FK
  • Notification:新增 RoomID uint(index,not null,default:0)、MessageID uint(not null,default:0) 字段;Room *ChatRoom(指针 FK,必须 constraint:-——同上,room_id=0 违反 FK);Preload Room 在通知列表中

GORM FK 约束陷阱(2026-09 首次迁移 bug):

  • GORM AutoMigrate 对指针类型关联(*ChatRoom/*ChatMessage)自动创建 DB 级 FK 约束,对值类型关联(User/Post)不创建
  • Notification.Room *ChatRoom 和 ChatRoom.LastMessage *ChatMessage 的 FK 约束在首次迁移时添加失败(notifications 表已有数据 room_id=0;chat_rooms 新建但 last_message_id=0)
  • 修复:模型加 constraint:- 标签防止新建 + db.go 的 dropStaleChatFKConstraints 函数在 AutoMigrate 前清理遗留约束(幂等 DROP CONSTRAINT IF EXISTS)

后端 Service(service/chat.go):

  • 建群:创建 ChatRoom + ChatRoomMember(owner),事务;群名 1-64 字符,描述 ≤256
  • 加入/退出/解散:member_count 实时维护;解散仅 owner,软删 room + 硬删 members;退出自动离开 WS 房间
  • 发消息:content 1-2000 字符,30 条/分钟限流;先落库 → 更新 room.last_message_id → 解析 @username 验证群成员 → 批量 CreateMention 通知 → WS 广播 chat:message
  • 历史分页:cursor 分页(before_id),每页 50 条,按 id ASC 返回;成员才能看历史
  • 未读数:ChatUnreadSummary 按 room_id GROUP BY 统计 id > last_read_message_id 的消息数;MarkChatRead 更新水位
  • 成员管理:列出成员(按 role DESC, created_at ASC);踢人仅 owner,事务内删除 member + WS RemoveUserRoom + 广播 chat:membership

后端 WS 扩展(realtime/hub.go):

  • 新增房间 chat:{roomID},joinRoom/leaveRoom 帧鉴权(查 ChatRoomMember 存在性)
  • 事件:chat:message{id,room_id,sender_id,content,created_at,sender}(BroadcastRoom chat:{roomID})、chat:membership{room_id,user_id,action}(BroadcastRoom)、notification:new{id}(单播 user:{id})
  • 踢人:RemoveUserRoom 立即从 chat:{roomID} 移除连接
  • 重连自动重订:前端 joinRoom 调用幂等,WS onopen 后自动重发 join 帧

后端 Handler/Router(handler/chat.go、router/router.go):

  • 路由组 /api/chat(RequireAuth + CSRF):
    • GET /rooms(分页+搜索,返回 ChatRoomView 含 joined/my_role/unread_count)
    • POST /rooms(建群)、GET /rooms/:id(详情)、PUT /rooms/:id(改名/描述,owner)、DELETE /rooms/:id(解散,owner)
    • POST /rooms/:id/join、POST /rooms/:id/leave
    • GET /rooms/:id/members、DELETE /rooms/:id/members/:uid(踢人,owner)
    • GET /rooms/:id/messages(历史分页)、POST /rooms/:id/messages(发消息,30/min 限流)
    • PUT /rooms/:id/read(标记已读)、GET /unread-summary(各群未读汇总)
  • /api/me 响应新增 chat_unread_count(全群未读总数)

前端:

  • lib/api.ts:ChatRoom/ChatRoomView/ChatMessage/ChatRoomMember 接口 + api 函数(fetchChatRooms/fetchChatRoom/apiCreateChatRoom/apiSendChatMessage/apiListChatMessages/apiMarkChatRead/apiListChatMembers/apiLeaveChatRoom/apiDissolveChatRoom/apiUpdateChatRoom/apiKickChatMember/apiChatUnreadSummary)
  • lib/realtime.ts:新增 RT_CHAT_MESSAGE/RT_CHAT_MEMBERSHIP/RT_NOTIFICATION_NEW 常量 + RtChatMessageData/RtChatMembershipData 接口;joinRoom/leaveRoom/clearRooms 方法;重连后自动重订已加入房间
  • app/chat/page.tsx:SSR 群列表 + 搜索 + 分页 + 建群弹窗(client component)
  • app/chat/[id]/page.tsx:SSR 群详情 + 初始 30 条消息 + ChatRoomClient
  • app/chat/[id]/ChatRoomClient.tsx:消息收发(WS 实时 + HTTP 发送)、历史分页向上加载、@高亮(群成员验证)、成员面板、设置面板(owner 改名/解散)、踢人、未读标记、自动滚底
  • components/Header.tsx:群聊图标入口(MessageSquare)+ 未读红点(chat_unread_count SSR 直出 + WS chat:message 实时递增,当前页不计数)
  • components/NotificationBell.tsx:订阅 RT_NOTIFICATION_NEW 实时递增未读 + 刷新列表

实测(2026-09-15):建群、消息收发、列表/详情 SSR、Header 未读红点全部通过;go build/vet/gofmt + next build 20 路由全过


Git / 仓库规则

  • 远端:origin = https://git.iioio.com/freefire/jiang13-bbs.git,主分支 main
  • push 重试规则(用户指定):远端是自建 Gitea(偶发不稳)。git push 首次失败时原样再试一次即可;不要反复重试、不要改 remote / 切换 SSH·HTTPS
  • 首次提交已重做:node_modules/、.next/、backend/app.ini、backend/data/(含 .jwt_secret)、.trae/ 均已加入 .gitignore,禁止入库
  • 配置模板提交 *.example(.env.example、backend/app.ini.example),真实配置文件不提交
  • 许可证:专有许可证(LICENSE,Copyright © 2026 姜十三,保留所有权利)。用户明确不允许他人免费使用/商用,源码仅可见;不要替换为 MIT/Apache/GPL 等开源协议

前端主题与配色规则

  • 配色修改仅允许改动 frontend/app/globals.css 中的 CSS 令牌,禁止修改组件代码
  • 明暗主题配色必须成组成对替换,确保一致性
  • 所有文字内容需满足 WCAG 2.1 AA 标准(对比度 ≥4.5:1),交互控件边界/焦点对比度 ≥3:1
  • 使用 Node 脚本核算对比度并生成截图回归验证(evidence/ 目录归档)
  • 后台主题色实时下发(2026-09):theme_accent SSR 内联在根布局 #j13-accent-vars;客户端换肤走 #j13-accent-runtime(见上「主题换肤 DOM 铁律」)。ThemeSync:WS settings:changed 优先,visibility/focus(10s 限频)HTTP 校准,无定时轮询;/admin/appearance 暂停同步避免覆盖未保存预览
  • 站长用户内容档案(2026-09-15):仅 owner 可访问 GET /api/admin/users/:id 及 posts/comments/messages(Unscoped 含驳回/软删/撤回标识);前端 /admin/users/[id];撤回消息保留正文;普通成员居中系统提示(本人 2 分钟内可「重新编辑」);站长/超管单气泡「已撤回」+ 原文(早期清空库内正文的记录无法还原)
  • 管理仪表盘:去掉与前台同源的 4 站点统计卡与快捷大卡;保留待办审核区 + 最新注册 + 板块分布

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
  • 板块卡片与帖子列表背景色必须保持一致(浅色均 #fff,暗色均 rgb(24,27,33))
  • 禁止操作自己的管理员账号;至少保留一名未封禁管理员
  • 角色参数必须使用白名单验证
  • 封禁与管理员降级操作需在同一事务内递增 token_version 并撤销 refresh tokens,使旧 JWT 立即失效
  • /api/logout 端点必须移出 RequireAuth 中间件,确保被封禁用户能清除 HttpOnly cookie
  • 封禁状态在后端持久化,所有认证入口(登录/refresh token/受保护 API/切回标签页)需实时校验并返回 code: "account_banned"
  • 前端使用单一强制下线事件总线统一感知封禁状态,实现状态清理与用户告知解耦
  • 管理员操作确认弹窗必须使用站点化设计(fixed inset-0 z-[1000] 遮罩 + panel 居中卡片),禁止使用原生 window.confirm
  • 管理员操作确认弹窗执行中需锁定 ESC/遮罩关闭,失败时保留弹窗并 toast 报错
  • 封禁告知模态需挂载在 root layout,确保任意页面都能弹出且不可误关

Engineering Conventions

  • Comment model includes parent_id, root_id, and depth columns
  • ListFloorPaged service assembles comment trees in memory with a single query to avoid N+1 issues
  • Main comments display floor numbers (continuous across pages); sub-comments and nested replies use indented tree display
  • Reply forms appear inline below target comments; quote replies use the format > @Author #Floor
  • 帖子详情页 Grid 轨道:lg:grid-cols-[224px_minmax(0,1fr)_280px]、xl:grid-cols-[240px_minmax(0,1fr)_300px],lg 起即为三栏
  • 帖子详情页左栏 aside 使用 hidden lg:flex w-56 xl:w-60(与首页 WorkspaceNav 同宽同断点)
  • 帖子详情页右栏 aside 使用 w-[280px] xl:w-[300px](与首页 CommunityPanels 一致)
  • 帖子详情页正文 article 去掉 max-w-[760px] mx-auto,使其占满剩余轨道
  • 帖子详情页窄屏作者卡跟随左栏断点由 xl:hidden 改为 lg:hidden,避免重复展示
  • 板块卡片选中态通过左侧 3×20px 圆角竖条和标题文字变 accent 色表达,不使用背景色区分
  • 管理员管理面板路由为 /admin/*,鉴权与 noindex 由 app/admin/layout.tsx 统一处理
  • 管理员侧边栏导航在 app/admin/AdminNav.tsx 中维护,新增模块通过修改 NAV_ITEMS 实现
  • 用户管理列表支持分页(默认 20,上限 50)、用户名/昵称/邮箱 ILIKE 搜索、角色与状态筛选
  • 用户管理操作后自动重新拉取数据以保证计数一致性;查询状态同步到 URL 参数(?q=&filter=&page=)
  • 管理员 API 路由经 RequireStaff → CSRF,功能点再由 RequirePerm 放行
  • 用户管理列表展示自己账号时仅显示"当前账号"标识,无操作按钮
  • 危险操作(如封禁、降级)需显示明确的 confirm 提示文案
  • 移动端用户管理列表使用卡片布局,桌面端使用七列表格布局
  • 管理员仪表盘快捷入口包含用户管理、公告管理、外观设置
  • 用户管理汇总卡显示总数(含今日新增)、管理员数量、已封禁数量及当前筛选结果数量
  • 搜索框使用 400ms 防抖处理
  • 管理员操作 API 错误码统一映射为 400/404
  • 用户管理列表分页控件使用上一页/下一页按钮
  • 帖子/评论计数通过 GROUP BY 批量填充,避免 N+1 查询问题
  • 汇总卡数据不受筛选条件影响
  • 管理员操作确认弹窗需区分四种操作独立文案与色调:提权(accent 盾牌)、降级(danger 盾叉,提示立即下线)、封禁(danger Ban)、解封(ok 旋转箭头)
  • 前端事件总线(userEvents.ts)需支持 j13:force-logout 事件,实现全局封禁状态感知
  • API 拦截器(api.ts)需在普通请求、401 刷新重试、refresh、/me 四处统一拦截封禁状态并去重派发事件
  • NotificationBell 组件需增加 visibilitychange 监听,确保切回标签页时立即探测封禁状态

Lessons Learned

  • Deleting a comment cascades soft deletion of the entire subtree
  • GORM bool 列零值陷阱(2026-09 登录日志 bug):Success bool gorm:"default:true" 时插入 false 会被 GORM 当零值省略,DB 落 default true,失败记录被错存成成功。规则:布尔列默认值必须是零值语义(default:false),写入时用 db.Select(全部列).Create() 强制写;AutoMigrate 不会改列默认值,需在 db.go 加幂等 ALTER TABLE ... ALTER COLUMN ... SET DEFAULT(参考 login_logs.success)
  • 待审内容 SSR 必须转发 cookie:pending/rejected 帖的详情与评论接口对游客 404,帖子详情页 SSR 所有数据拉取(含 generateMetadata、fetchComments)都要带 authCookieHeader,否则作者提交后跳转看到 500/"帖子不存在";404 用 next/navigation 的 notFound() 落全局 not-found 页而非让页面抛 500
  • Windows PowerShell 5.1 跑 .ps1 脚本:含中文的脚本文件必须存成 UTF-8 with BOM,否则按 ANSI 解析直接语法错误;中文断言匹配需用 UTF8 字节数组匹配,IWR .Content 会乱码;npx/npm 必须用 npx.cmd/npm.cmd
  • 圆形元素需设置 self-center justify-self-center w-full 避免 Grid 布局中被拉伸成椭圆;内部铺满元素使用 absolute inset-0 而非 h-full 解决 aspect-ratio 尺寸解析歧义
  • <Avatar> 所有调用点必须显式传 src={xxx.avatar || undefined},否则默认渲染字母头像
  • 搜索清除后应立即拉取数据,避免显示延迟
  • 空筛选状态需显示正确的提示文案
  • SSR 深链 ?filter=admin|banned 应能正确直出筛选结果
  • koa-connect wrapper caused ctx leaks when migrating Express middleware to Koa; native Koa rewrite is required instead of wrapping Express middleware
  • 使用 koa-connect 包装 Express 中间件会导致 ctx.state 数据丢失,需采用原生 Koa 中间件重写

文件上传基础设施(2026-09 起)

  • 全站图片策略:用户上传的图片统一 WebP。头像裁剪/转码在浏览器端完成(Canvas → toBlob('image/webp')),后端只收 WebP
  • 存储:backend/data/uploads/avatars/{随机hex32}.webp(已在 backend/data/ gitignore 内),Gin r.Static("/uploads", ...) 提供静态服务;Next rewrite 新增 /uploads/:path* 同源代理到后端(与 /api 一样走 BACKEND_URL 兜底 localhost:3001)
  • 本地开发数据目录铁律(2026-09-16):config.Parse 从 cwd 向上解析 backend 根,不跟 os.Getwd() 走。规范启动 cd backend && go run ./cmd/jiang13;数据只有 backend/data/。在 cmd/jiang13/ 启动会写出另一套 cmd/jiang13/data,DB 里头像 404。规则见 .cursor/rules/jiang13-bbs-local-dev.mdc;可用 JIANG13_WORK_PATH 覆盖。启动日志打印 workPath/dataDir。:3001 已占用则不要再开第二个后端
  • 本地进程停干净(2026-09-16):Windows 上停 Cursor 终端常留下 go/node 孤儿听端口。用户用仓库根 stop.bat(Unix:./stop.sh)按 :3000/:3001 + taskkill /T 杀进程树;agent 禁止只杀 shell、停后须复核端口空闲。详见 jiang13-bbs-local-dev.mdc
  • DB:attachments 表(user_id+kind 联合索引,kind 取值 avatar/image,记录 url/mime/size/width/height);service/upload.go 校验 RIFF/WEBP 魔数 + golang.org/x/image/webp 解码尺寸(正方形 64~512px)+ 大小限制以裁剪后文件为准(≤2MiB,MaxBytesReader 限请求体),落盘与 user.avatar 更新在同一事务
  • 路由(需登录 + CSRF):POST /api/upload/avatar(multipart 字段 file)、PUT /api/avatar/use(只能选用本人 attachments 中的 url)、GET /api/my/media(本人全部附件,id DESC 限 60 条)、DELETE /api/my/attachments/:id(被帖子 content 引用返回 409;是当前头像则事务内置空 user.avatar;成功后物理删除文件)
  • 头像只能走上传/媒体库选用通道:UpdateProfile 已移除 avatar 字段,禁止通过资料接口写任意外链 URL
  • 前端:react-easy-crop v6(必须 import "react-easy-crop/react-easy-crop.css"),裁剪弹窗 components/AvatarCropModal.tsx(上传区 + 媒体库 6 列圆形网格:点任意图进裁剪,非当前图 hover 两阶段确认删除),可点击头像 components/AvatarPicker.tsx(仅资料表单内一个入口,Hero 头像纯展示;按钮必须显式 width/height + inline-flex + line-height:0,否则 inline 基线 strut 致 80×86 椭圆),输出固定 320×320、质量 0.92→0.65 自适应直到 ≤2MB。媒体库网格圆角光环另有一坑:CSS Grid 项默认 align-self:stretch 会与 aspect-ratio:1/1 冲突把容器拉高成竖椭圆(下方露月牙瓣),网格项必须加 self-center justify-self-center w-full,内部铺满元素用 absolute inset-0 而非 h-full(aspect-ratio 尺寸上百分比高度解析有歧义)
  • 头像全局同步:持久 root layout 的 Header 等客户端组件不能只靠 router.refresh();lib/userEvents.ts 事件总线(j13:user-updated CustomEvent,emitUserUpdate/onUserUpdate)在上传/选用/删除后广播 patch,Header/AvatarPicker 订阅;Avatar 组件 img 带 onError 回退字母头像(附件删除后不裂图),并在 src 变化时重置失败态。教训:Avatar 默认渲染字母头像,<Avatar> 所有调用点必须显式传 src={xxx.avatar || undefined}——曾因 Header 用户菜单/PostRow/PinnedPosts/CommentSection/post 详情页 9 处漏传 src 导致全站只显示字母头像(后端 JSON 本就带 user.avatar,纯前端遗漏)
  • 注意:CSRF token 是 base64.URLEncoding(含结尾 =),从 cookie 读取时不能用 split('=')[1](会截断),要用正则 ([^;]+),见 lib/api.ts getCSRFToken