问题8 登录系统: - node:sqlite 账号存储(scrypt 哈希 + HMAC 签名 token),preset admin 改为 e2hang/evan1115 - /api/auth/register|login|me,me 返回 room 供重联 - 网关拦截 create/join 需登录(401),列表保持开放 踢人逻辑修正(问题2 延伸): - kickMove 真正释放座位(folded=false, name=null)而非锁死 - war/mill 去掉永久封禁语义 管理员越权:管理员 token 可删任意房间、踢任意房间内的人 单房间约束(用户新需求): - 同一用户同时只能在一个房间,create/join 他房返回 409 - 踢人后若房间仅剩踢人者则自动解散并释放其记录(修复 2 人局踢光对手后被困 409) - 陈旧房间记录兜底清理 UI 清理:移除"改名"按钮与"你的名字"输入框;删除房间按钮仅房主/管理员可见 docs: 新增 12-auth-kick-single-room.md 汇总本会话全部需求/根因/修复/边界测试 Co-Authored-By: Claude (CodeBuddy) <noreply@codebuddy.ai>
11 KiB
11 KiB
问题 8/2 延伸:登录系统落地 + 踢人修正 + 单房间约束 + 权限收口
本文档汇总本会话内围绕「登录身份(问题 8)」「踢人逻辑(问题 2 延伸)」「单房间约束(用户新需求)」 「管理员越权」「UI 清理」所做的工作:用户原始要求、发现的问题、根因分析、修复方案、验证结果。 配套的代码改动已落地于
packages/server、packages/engine、packages/ui、apps/web。
一、用户提出的要求(按时间顺序)
- 预设管理员账号:指定 preset admin 账号
e2hang/ 密码evan1115(取代原默认admin/admin123)。 - 清理测试账号:删除遗留的
alice测试用户,DB 只保留 admin。 - 踢人逻辑修正:踢人后要把空位置留出来,而不是写一个"伪弃牌"把座位锁死(之前
kickMove把folded=true导致该座位谁也坐不进去)。 - 管理员越权:管理员(以 admin 身份登录)可以删除任意房间、在任意房间踢出任何人,不受"必须是房主"限制。
- 普通用户进房 + 按钮权限:普通用户必须能进入房间;只有房主和管理员有"踢人"按钮,普通用户没有。
- 单房间约束 + 自动重连:同一登录用户不能同时进入两个房间;每次重连自动进入他所在的那个房间。
- UI 清理:去掉"改名"按钮和"你的名字"输入框;普通用户(非房主非管理员)不显示"删除房间"按钮。
- 踢人后空房解救(后续 bug 修复):2 人局踢掉唯一对手后,房主被困在空房、无法创建新房间(报 409),需要让空房自动解散并释放房主。
二、现状与根因
2.1 登录系统(问题 8)
- 完成了
auth-db.ts(账号存储 + scrypt 哈希 + HMAC 签名 token)+auth.ts(/api/auth/register|login|me)。 - token 用
base64url(JSON{u,exp}).signature格式,HMAC-sha256 签名;TOKEN_TTL_MS = 30 天。 - 账号用
node:sqlite落盘(Node 22 内置,无原生编译),DB 文件packages/server/data/tts-like.db;之前计划的内存 Map 改为 SQLite(跨重启保留)。 - 预设 admin:
auth-db.ts的PRESET_USERS改为{ username: 'e2hang', password: 'evan1115', role: 'admin' }(支持AUTH_ADMIN_USER/AUTH_ADMIN_PASS环境变量覆盖),首次启动INSERT OR IGNORE注入。
2.2 踢人逻辑错误(问题 2 延伸)
- 根因:
holdem.ts的kickMove在被踢者上设了tp.folded = true。UI 中"踢人"按钮条件、以及座位是否可加入的判断,会把这个folded=true的座位当成"已占用/已弃牌",导致该位置既显示有人又无法被新玩家 join。 - 修复:
kickMove改为真正释放座位——tp.seated = false; tp.inHand = false; tp.folded = false; // 原来是 true → 锁死座位 tp.allIn = false; tp.name = null; // 座位显示为"空" tp.stack = 0;war.ts/mill.ts的kickmove 也去掉了"永久封禁"语义(G.kicked[target]=true改为无副作用占位),并移除各 move 里if (G.kicked?.[me]) return;的守卫。 - 效果:踢人后座位变空,新玩家可正常进入(带新 credentials)。已单测验证(
holdem.test.ts期望folded===false、name===null)。
2.3 管理员越权
rooms.ts的delete/kick处理器新增isAdmin(app, token)判定:携带有效 token 且role==='admin'时,跳过"必须是房主"校验。- token 取自
Authorization: Bearer <token>或?token=(与auth.ts同款解析,避免直接依赖auth.ts的node:sqlite链,防止 vitest 解析失败)。
2.4 普通用户进房 + 按钮权限
- 进房:服务端 lobby REST 本来就允许普通用户 list+join(经
/api与 vite 代理均 200 验证)。早前"普通用户进不去"是因为生产dist是旧版、缺登录 UI 导致无 token 被网关 401;重建dist后解决。 - 踢人按钮可见性(
LobbyRoomList):仅房主(createdBy 且是本人座位)或管理员可见"踢"按钮;普通用户看不到。const canKick = isAdmin || (isCurrent && currentPlayerId === m.createdBy); - 管理员"删"按钮:每个房间 pill 对
isAdmin渲染"删"按钮(确认后roomsDelete带 token,不需要房主 credentials)。
2.5 单房间约束(用户新需求)
- 根因/设计:需要阻止"同一登录用户同时处于两个房间",且重连时自动回原房间。
- 服务端实现(
rooms.ts,在mountApiPrefix之前注册,以保留/api/games/...原始路径):- 内存 Map:
userRoom(用户名→matchID)、roomPlayers(matchID→playerID→用户名)。 - 中间件拦截
create/join/leave:create/join前:若userRoom.get(username)指向别的房间 →409(中文提示"先离开才能创建/加入其他房间")。- 成功后(
await next之后):recordUserRoom登记;create的 matchID 从ctx.body.matchID读取。 leave成功后:清userRoom/roomPlayers对应记录。
kick/delete处理器内:清被踢用户 / 全房间的单房间记录。
- 内存 Map:
- 自动重连:
- 客户端
config(matchID/playerID/credentials)经localStorage持久化,刷新即自动重连原房间。 - 服务端
/api/auth/me新增返回room(来自getUserRoom),供客户端重联时权威确认所在房间(已通过setupAuth({ getUserRoom })注入)。 - 客户端
LobbyRoomList.errMsg()把 409 的error文案友好展示。
- 客户端
2.6 踢人后空房解救(关键 bug 修复)
- 现象:2 人局,房主踢掉唯一对手后,房间列表里房间还在(房主自己仍
seated有 name),me.room仍指向它,于是房主再create→409 "你已在房间 X 中"。用户感知为"房间消失但被卡住"。 - 根因:单房间约束只认
userRoom记录,而空房里房主仍被记录为"在房内",无退出途径(除非手动"退出房间",但用户没意识到自己还在房间里)。 - 修复(
rooms.tskick 处理器第 4 步):踢人后检查"除踢人者本人外是否还有其他人就座(metadata 里有name)":- 若
othersSeated === false(房间只剩踢人者自己/空了)→ 视为对局已散,server.db.wipe(matchID)+ 清presence/createdBy/roomPlayers,释放踢人者的单房间记录。 - 于是 2 人局踢掉最后一人 → 房间消失、房主
me.room清空、可立即创建新房(200)。 - 3 人及以上局踢掉一人但还有别人 → 房间保留,符合预期。
- 若
- 陈旧记录兜底:
create/join前先roomExists(server, existing)检查userRoom指向的房间是否还存在(如被管理员删),不存在则清掉陈旧记录,避免"房间没了却卡 409"。
2.7 UI 清理
- 移除
LobbyRoomList的"改名"按钮 +renameMecallback +onRenameprop(引擎层renameMove保留,仅去 UI 入口)。 - 移除
App.tsx联机模式下的"你的名字"输入框(playerName状态保留,登录时自动用登录名填充,join/create 时作玩家名)。 - 删除房间按钮可见性:新增
showDelete = currentMatchId && (isAdmin || isCurrentHost),仅房主/管理员可见;普通用户看不到。 - web
dist重建确认:"你的名字"已从产物移除;"改名" UI 已移除(引擎日志里的"X 改名"字样属后端逻辑,非按钮)。
三、关键架构事实(本会话确认)
mountApiPrefix会把/api前缀剥离;rooms.ts的自定义中间件必须在mountApiPrefix之前注册,才能在ctx.path仍是/api/games/...//api/rooms/...时拦截。- boardgame.io lobby 路由(已对
server.js核实):POST /games/:name/create→ body 返回{ matchID }(create 成功后才能拿到 matchID)。POST /games/:name/:id/join→ body{ playerID, playerName }。POST /games/:name/:id/leave→ body{ playerID, credentials }。
node:sqlite在 vitest 下无法被 transform 解析;rooms.ts对AuthDB改用import type(编译期擦除)+ 本地内联extractToken/usernameFromCtx,避免运行时拉入node:sqlite(否则 server 单测 12 例会崩)。- 踢人"释放 lobby 座位":server 直接改
metadata.players[target].name/credentials = undefined(镜像原生leave),再 best-effort 经Masterdispatch 调游戏内kickmove(回合制游戏守卫可能 no-op,但 metadata 已权威释放座位)。
四、边界测试(端到端,对运行中的 :8000)
| Case | 场景 | 结果 |
|---|---|---|
| 1 | 3 人局踢 1 人剩 2 人 | 座位清空、房间保留 |
| 2 | 2 人局踢唯一对手 | 房间解散(404)、me.room 清空、房主可开新房(200) |
| 3 | 踢空座位(未就座) | 200、无副作用、房间保留 |
| 4 | 非房主/非管理员踢人 | 403、座位不变 |
| 5 | 被踢者重进同一座位 | 重进 200、成功补位 |
| 6 | 踢自己(target=playerID) | 400 invalid target、房间保留 |
| 7 | 3 人就座连续踢 2 人剩房主 | 房间解散(404)、me.room 清空 |
| 8 | 管理员 token 跨房踢人 | 200、目标移出、房间保留 |
| 9 | 无效 target(座位 9) | 200、无副作用 |
| 10 | 房主带错 credentials | 403、座位不变 |
| 11 | 单房间:在 A 房时 create/join B 房 | 409(中文提示) |
| 12 | 在 A 房时 join A 房另一座位 | 200(同房间允许) |
| 13 | leave A 房后 | me.room 清空、可再次 create(200) |
| 14 | 房间被外部删除后 create | 陈旧兜底释放、create 200 |
五、验证与质量门禁
pnpm -r ts:全 workspace 类型检查通过。- engine 测试 92 passed(含
holdem.test.ts踢人/单测,kick 期望folded===false、name===null)。 - server 测试 12 passed:原 2 个踢人测试因"2 人局踢后房间不再保留"改为断言房间解散(404);新增 3 人局变体断言房间保留 + 非房主 403。
- web
dist重建(vite build),确认登录 UI、409 文案、单房间约束均已生效。 - 所有临时测试账号清理,
packages/server/data/tts-like.db仅留e2hang(admin)。 - server 以
tsx watch运行,源码改动热加载,无需手动重启。
六、已知限制 / 后续建议
userRoom/roomPlayers/presence为 内存态,server 重启即清空(与 lobby metadata 同为 InMemory,保持一致)。重启后用户仍可凭localStorage里的config自动重连;此时 server 不记得该用户的"单房间"记录,但因客户端仍有 credentials,不会误拦(陈旧兜底进一步保护)。- 若需"server 重启后也记住谁在哪个房间",需把
userRoom也落盘(如 SQLite 表),但这与 lobby metadata 的 InMemory 不一致,建议保持现状。 - 踢人后"房间解散"的判断基于"除踢人者外无他人就座";若希望房主踢光对手后保留空房等新人,可改为仅释放房主记录而不 wipe——当前按用户反馈选了"解散 + 释放"。