### 详细变更描述 * **修复 JWT 签名冲突**:重构 `LoginService.generateTokenPair()`,移除载荷(Payload)中的 `iss` (issuer) 与 `aud` (audience) 字段,解决签名校验失败的问题。 * **统一验证逻辑**:更新 `ZulipService` 以调用 `LoginService.verifyToken()`,消除重复的 JWT 校验代码,确保逻辑单一职责化(Single Responsibility)。 * **修复硬编码 API 密钥问题**:消息发送功能不再依赖静态配置,改为从 Redis 动态读取用户真实的 API 密钥。 * **解耦依赖注入**:在 `ZulipModule` 中注入 `AuthModule` 依赖,以支持标准的 Token 验证流程。 * **完善技术文档**:补充了关于 JWT 验证流程及 API 密钥管理逻辑的详细文档。 * **新增测试工具**:添加 `test-get-messages.js` 脚本,用于验证通过 WebSocket 接收消息的功能。 * **更新自动化脚本**:同步更新了 API 密钥验证及用户注册校验的快速测试脚本。 * **端到端功能验证**:确保消息发送逻辑能够正确映射并调用用户真实的 Zulip API 密钥。
217 lines
8.2 KiB
Markdown
217 lines
8.2 KiB
Markdown
游戏属性: 2d社交属性, 无战斗的社群mmo游戏
|
||
核心目的(游戏内外无缝互通): 不玩这个游戏但是在zulip的社群成员, 也可以跨平台和游戏内的成员聊天
|
||
|
||
核心设计理念:
|
||
Stream (流) -> Topic (话题) 线程模型,天然契合 MMO 中的 Zone (区域) -> Context (情境) 逻辑
|
||
我们需要解决的核心问题是:如何将2D 空间位置(Game State)映射到Zulip 的信息组织形式(Message State),同时利用 Zulip 的 API Key 机制完成无缝认证
|
||
|
||
---
|
||
1. 核心逻辑架构 (The Core Logic)
|
||
在设计 API 之前,我们需要定义 mappings(映射关系):
|
||
- Game World / Map ←→ Zulip Stream (e.g., #Novice_Village)
|
||
- Interactive Object / Event ←→ Zulip Topic (e.g., Notice Board, Tavern Gossip)
|
||
- Whisper / Party ←→ Zulip Private Message
|
||
|
||
---
|
||
架构图示:
|
||
Client (Game) $$\xrightarrow{\text{Game Token}}$$ Game Middleware API $$\xrightarrow{\text{Zulip API Key}}$$ Zulip Server
|
||
$$Client (Godot) \xleftrightarrow{\text{WebSocket}} Node.js Server \xleftrightarrow{\text{REST/Long-Poll}} Zulip Server$$
|
||
设计理由:不建议让客户端直接直连 Zulip。我们需要一层中间件(Middleware)来控制权限、注入游戏数据(如玩家坐标、当前的动作状态),并防止用户在该 API Key 下进行非游戏允许的 Zulip 操作(如随意创建 Stream)。
|
||
|
||
---
|
||
2. 设计思路一: "统一网关"(Unified Gateway)
|
||
2.1 详细数据流设计 (Data Flow)
|
||
我们需要在 Node.js 中维护一个 Session Manager。
|
||
A. 登录与握手 (Initialization)
|
||
1. Godot: 发送登录包 {"type": "login", "token": "user_game_token"}。
|
||
2. Node.js:
|
||
- 验证游戏 Token。
|
||
- 查找该用户的 Zulip API Key(通常存储在数据库中,或者首次登录时让用户提供)。
|
||
- 关键步骤: Node.js 服务器为该特定用户实例化一个 Zulip Client,并向 Zulip 申请注册一个 Event Queue。
|
||
- 将 Socket_ID 与 Zulip_Queue_ID 绑定。
|
||
B. 发送消息 (Upstream: Godot -> Node -> Zulip)
|
||
1. Godot: 玩家输入 "Hello",Godot 通过 WebSocket 发送简化的包:
|
||
2. JSON
|
||
{
|
||
"t": "chat",
|
||
"content": "Hello",
|
||
"scope": "local" // 或者 "topic_name"
|
||
}
|
||
1. Node.js:
|
||
- 收到包,解析出这是聊天请求。
|
||
- 上下文注入: Node 知道玩家当前在 Map_101 (对应 Zulip Stream #Tavern)。
|
||
- API 调用: Node 使用该用户的 Zulip Client,调用 Zulip API 发送消息到 #Tavern。
|
||
- 优势: 这里可以做风控(比如禁止发脏话、频率限制),Godot 端根本无法绕过。
|
||
C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
||
1. Node.js:
|
||
- 服务器内部有一个循环(或者异步监听器),轮询 Zulip 的事件队列。
|
||
- 收到 Zulip 的 message 事件:User_B 在 #Tavern 说了 "Hi"。
|
||
- 空间过滤: Node 检查当前连接的所有 WebSocket,找出所有位于 Map_101 的玩家。
|
||
- 广播: 将消息打包成游戏协议,通过 WebSocket 推送给这些玩家:
|
||
2. JSON
|
||
{
|
||
"t": "chat_render",
|
||
"from": "User_B",
|
||
"txt": "Hi",
|
||
"bubble": true
|
||
}
|
||
1. Godot: 收到包,直接调用 show_bubble()。
|
||
|
||
---
|
||
2.3 这个方案的权衡分析 (Trade-off Analysis)
|
||
这种改变带来的本质变化:
|
||
优势 (The Wins)
|
||
1. 客户端极度简化 (Thin Client):
|
||
- Godot 里不需要写 HTTP Request,不需要处理 Long Polling 的异常断连,不需要解析复杂的 JSON 结构。
|
||
- Godot 只需要处理 on_websocket_packet_received。
|
||
2. 安全性 (Security):
|
||
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
|
||
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
||
3. 协议统一:
|
||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
||
|
||
|
||
---
|
||
|
||
## 3. JWT Token 验证和 API Key 管理 (v1.1.0 更新)
|
||
|
||
### 3.1 用户注册和 API Key 生成流程
|
||
|
||
当用户注册游戏账号时,系统会自动创建对应的 Zulip 账号并获取 API Key:
|
||
|
||
```
|
||
用户注册 (POST /auth/register)
|
||
↓
|
||
1. 创建游戏账号 (LoginService.register)
|
||
↓
|
||
2. 初始化 Zulip 管理员客户端
|
||
↓
|
||
3. 创建 Zulip 账号 (ZulipAccountService.createZulipAccount)
|
||
- 使用相同的邮箱和密码
|
||
- 调用 Zulip API: POST /api/v1/users
|
||
↓
|
||
4. 获取 API Key (ZulipAccountService.generateApiKeyForUser)
|
||
- 使用 fetch_api_key 端点(固定的、基于密码的 Key)
|
||
- 注意:不使用 regenerate_api_key(会生成新 Key)
|
||
↓
|
||
5. 加密存储 API Key (ApiKeySecurityService.storeApiKey)
|
||
- 使用 AES-256-GCM 加密
|
||
- 存储到 Redis: zulip:api_key:{userId}
|
||
↓
|
||
6. 创建账号关联记录 (ZulipAccountsRepository)
|
||
- 存储 gameUserId ↔ zulipUserId 映射
|
||
↓
|
||
7. 生成 JWT Token (LoginService.generateTokenPair)
|
||
- 包含用户信息:sub, username, email, role
|
||
- 返回 access_token 和 refresh_token
|
||
```
|
||
|
||
### 3.2 JWT Token 验证流程
|
||
|
||
当用户通过 WebSocket 登录游戏时,系统会验证 JWT Token 并获取 API Key:
|
||
|
||
```
|
||
WebSocket 登录 (login 消息)
|
||
↓
|
||
1. ZulipService.validateGameToken(token)
|
||
↓
|
||
2. 调用 LoginService.verifyToken(token, 'access')
|
||
- 验证签名、过期时间、载荷
|
||
- 提取用户信息:userId, username, email
|
||
↓
|
||
3. 从 Redis 获取 API Key (ApiKeySecurityService.getApiKey)
|
||
- 解密存储的 API Key
|
||
- 更新访问计数和时间
|
||
↓
|
||
4. 创建 Zulip 客户端 (ZulipClientPoolService.createUserClient)
|
||
- 使用真实的用户 API Key
|
||
- 注册事件队列
|
||
↓
|
||
5. 创建游戏会话 (SessionManagerService.createSession)
|
||
- 绑定 socketId ↔ zulipQueueId
|
||
- 记录用户位置信息
|
||
↓
|
||
6. 返回登录成功
|
||
```
|
||
|
||
### 3.3 消息发送流程(使用正确的 API Key)
|
||
|
||
```
|
||
发送聊天消息 (chat 消息)
|
||
↓
|
||
1. ZulipService.sendChatMessage()
|
||
↓
|
||
2. 获取会话信息 (SessionManagerService.getSession)
|
||
- 获取 userId 和当前位置
|
||
↓
|
||
3. 上下文注入 (SessionManagerService.injectContext)
|
||
- 根据位置确定目标 Stream/Topic
|
||
↓
|
||
4. 消息验证 (MessageFilterService.validateMessage)
|
||
- 内容过滤、频率限制
|
||
↓
|
||
5. 发送到 Zulip (ZulipClientPoolService.sendMessage)
|
||
- 使用用户的真实 API Key
|
||
- 调用 Zulip API: POST /api/v1/messages
|
||
↓
|
||
6. 返回发送结果
|
||
```
|
||
|
||
### 3.4 关键修复说明
|
||
|
||
**问题 1: JWT Token 签名冲突**
|
||
- **原因**: payload 中包含 `iss` 和 `aud`,但 `jwtService.signAsync()` 也通过 options 传递这些值
|
||
- **修复**: 从 payload 中移除 `iss` 和 `aud`,只通过 options 传递
|
||
- **文件**: `src/business/auth/services/login.service.ts`
|
||
|
||
**问题 2: 使用硬编码的旧 API Key**
|
||
- **原因**: `ZulipService.validateGameToken()` 使用硬编码的测试 API Key
|
||
- **修复**: 从 Redis 读取用户注册时存储的真实 API Key
|
||
- **文件**: `src/business/zulip/zulip.service.ts`
|
||
|
||
**问题 3: 重复实现 JWT 验证逻辑**
|
||
- **原因**: `ZulipService` 自己实现了 JWT 解析
|
||
- **修复**: 复用 `LoginService.verifyToken()` 方法
|
||
- **文件**: `src/business/zulip/zulip.service.ts`, `src/business/zulip/zulip.module.ts`
|
||
|
||
### 3.5 API Key 安全机制
|
||
|
||
**加密存储**:
|
||
- 使用 AES-256-GCM 算法加密
|
||
- 加密密钥从环境变量 `ZULIP_API_KEY_ENCRYPTION_KEY` 读取
|
||
- 存储格式:`{encryptedKey, iv, authTag, createdAt, updatedAt, accessCount}`
|
||
|
||
**访问控制**:
|
||
- 频率限制:每分钟最多 60 次访问
|
||
- 访问日志:记录每次访问的时间和次数
|
||
- 安全事件:记录所有关键操作(存储、访问、更新、删除)
|
||
|
||
**环境变量配置**:
|
||
```bash
|
||
# 生成 64 字符的十六进制密钥(32 字节 = 256 位)
|
||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||
|
||
# 在 .env 文件中配置
|
||
ZULIP_API_KEY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||
```
|
||
|
||
### 3.6 测试验证
|
||
|
||
使用测试脚本验证功能:
|
||
|
||
```bash
|
||
# 测试注册用户的 Zulip 集成
|
||
node docs/systems/zulip/quick_tests/test-registered-user.js
|
||
|
||
# 验证 API Key 一致性
|
||
node docs/systems/zulip/quick_tests/verify-api-key.js
|
||
```
|
||
|
||
**预期结果**:
|
||
- ✅ WebSocket 连接成功
|
||
- ✅ JWT Token 验证通过
|
||
- ✅ 从 Redis 获取正确的 API Key
|
||
- ✅ 消息成功发送到 Zulip
|
||
|
||
---
|