# 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`(全站唯一,迁移把 id=1 testuser 升站长,唯一硬保护:不可改角色/封禁) > `super_admin`(全站后台,仅 owner 可授予) > `admin`(公告+全站帖/评论审核+置顶加精) > `board_admin`(仅 user_boards 授权板块帖/评论审核) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上 - Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids);RequirePerm(PermAnnouncements/PermUsers/PermSettings) 挂在 staffAPI 子组;板块隔离在 service 层按 Actor.BoardIDs - 发帖/评论: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 - 登录历史 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(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 是字符串不是数组 --- ## 认证方案(HttpOnly Cookie + CSRF + Refresh Token + Token Version) **不使用 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 `