本机仍为「当前在线」,Chrome/Edge 等同时登录的其它会话显示「在线」并可剔除。 Co-authored-by: Cursor <cursoragent@cursor.com>
31 KiB
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)。
记忆初始化指令
以下为项目核心规则,所有后续开发必须遵守:
- 项目使用 Go 1.27 + Next.js 16,禁止生成旧版本惯用代码
- Next.js 16 的 params 等必须 await,这是最高频错误,每次生成后自查
- 如果部署环境涉及 Cloudflare Workers,middleware.ts 不能改名为 proxy.ts
- 所有 SEO 关键内容必须在初始 HTML 中,不接受客户端注入
- 权限逻辑只在 Go 端,前端不做权限判定
- 遇到不确定的 Next.js 16 或 Go 1.27 特性,先查官方文档再生成代码
- 用户未明确告知「已正式部署到生产环境」之前:禁止为旧版本/旧契约写兼容冗余(双写双读、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 列
- 用户安全设置「最近登录设备」: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_tokencookie 读取(不接受 Authorization Bearer)ValidateClaims每请求查 DB:校验token_version匹配 +banned状态(不依赖 JWT 缓存值)CSRFMiddleware对非 GET 方法校验X-CSRF-Tokenheader 与j13_csrfcookie 一致- 需登录 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-Tokenheader 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_URLapp/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+visibilitychangeHTTP 校准(已取消 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值类型 FKChatMessage:room_id+created_at 复合索引、sender_id、content(varchar 2000)、mention_ids(逗号分隔 user_id,落通知用) + soft delete;Sender User值类型 FKNotification:新增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/leaveGET /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 条消息 + ChatRoomClientapp/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:WSsettings: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, anddepthcolumns ListFloorPagedservice 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 内),Ginr.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-updatedCustomEvent,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