问题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>
137 lines
11 KiB
Markdown
137 lines
11 KiB
Markdown
# 问题 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>` 或 `?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——当前按用户反馈选了"解散 + 释放"。
|