# 问题 8/2 延伸:登录系统落地 + 踢人修正 + 单房间约束 + 权限收口 > 本文档汇总本会话内围绕「登录身份(问题 8)」「踢人逻辑(问题 2 延伸)」「单房间约束(用户新需求)」 > 「管理员越权」「UI 清理」所做的工作:用户原始要求、发现的问题、根因分析、修复方案、验证结果。 > 配套的代码改动已落地于 `packages/server`、`packages/engine`、`packages/ui`、`apps/web`。 --- ## 一、用户提出的要求(按时间顺序) 1. **预设管理员账号**:指定 preset admin 账号 `e2hang` / 密码 `evan1115`(取代原默认 `admin`/`admin123`)。 2. **清理测试账号**:删除遗留的 `alice` 测试用户,DB 只保留 admin。 3. **踢人逻辑修正**:踢人后要把**空位置留出来**,而不是写一个"伪弃牌"把座位锁死(之前 `kickMove` 把 `folded=true` 导致该座位谁也坐不进去)。 4. **管理员越权**:管理员(以 admin 身份登录)可以**删除任意房间、在任意房间踢出任何人**,不受"必须是房主"限制。 5. **普通用户进房 + 按钮权限**:普通用户必须能进入房间;**只有房主和管理员**有"踢人"按钮,普通用户没有。 6. **单房间约束 + 自动重连**:同一登录用户不能同时进入两个房间;每次重连自动进入他所在的那个房间。 7. **UI 清理**:去掉"改名"按钮和"你的名字"输入框;普通用户(非房主非管理员)不显示"删除房间"按钮。 8. **踢人后空房解救**(后续 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` 改为真正释放座位—— ```ts tp.seated = false; tp.inHand = false; tp.folded = false; // 原来是 true → 锁死座位 tp.allIn = false; tp.name = null; // 座位显示为"空" tp.stack = 0; ``` `war.ts` / `mill.ts` 的 `kick` move 也去掉了"永久封禁"语义(`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=`(与 `auth.ts` 同款解析,避免直接依赖 `auth.ts` 的 `node:sqlite` 链,防止 vitest 解析失败)。 ### 2.4 普通用户进房 + 按钮权限 - **进房**:服务端 lobby REST 本来就允许普通用户 list+join(经 `/api` 与 vite 代理均 200 验证)。早前"普通用户进不去"是因为生产 `dist` 是旧版、缺登录 UI 导致无 token 被网关 401;重建 `dist` 后解决。 - **踢人按钮可见性**(`LobbyRoomList`): ```ts const canKick = isAdmin || (isCurrent && currentPlayerId === m.createdBy); ``` 仅**房主(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` 处理器内:清被踢用户 / 全房间的单房间记录。 - **自动重连**: - 客户端 `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.ts` kick 处理器第 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` 的"改名"按钮 + `renameMe` callback + `onRename` prop(引擎层 `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 经 `Master` dispatch 调游戏内 `kick` move(回合制游戏守卫可能 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——当前按用户反馈选了"解散 + 释放"。