Files
jiang13-bbs/.cursor/project_memory.md
freefire 5052bcd805 chore(dev): 按端口杀进程树,避免 Windows 孤儿监听
新增 stop.bat/ps1/sh,并写入本地开发规则:停服务用脚本、停后复核端口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 07:27:45 +08:00

293 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
---
## 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 列
- 角色变更事务内 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 `<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