Files
huajishe-tts/docs/texas/12-auth-kick-single-room.md
e2hang 04d0af515c feat: 登录系统落地 + 踢人修正 + 单房间约束 + 权限收口
问题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>
2026-08-25 14:13:59 +08:00

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