feat(login, zulip): 引入 JWT 验证并重构 API 密钥管理 #34
@@ -358,7 +358,16 @@ node test-stream-initialization.js
|
|||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
### v2.0.0 (2025-12-25)
|
### v1.1.0 (2026-01-06)
|
||||||
|
- **修复 JWT Token 验证和 API Key 管理**
|
||||||
|
- 修复 `LoginService.generateTokenPair()` 的 JWT 签名冲突问题
|
||||||
|
- `ZulipService` 现在复用 `LoginService.verifyToken()` 进行 Token 验证
|
||||||
|
- 修复消息发送时使用错误的硬编码 API Key 问题
|
||||||
|
- 现在正确从 Redis 读取用户注册时存储的真实 API Key
|
||||||
|
- 添加 `AuthModule` 到 `ZulipModule` 的依赖注入
|
||||||
|
- 消息发送功能现已完全正常工作 ✅
|
||||||
|
|
||||||
|
### v1.0.1 (2025-12-25)
|
||||||
- 更新地图配置为 9 区域系统
|
- 更新地图配置为 9 区域系统
|
||||||
- 添加 Stream Initializer Service 自动初始化服务
|
- 添加 Stream Initializer Service 自动初始化服务
|
||||||
- 更新默认出生点为鲸之港 (Whale Port)
|
- 更新默认出生点为鲸之港 (Whale Port)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### 连接地址
|
### 连接地址
|
||||||
|
|
||||||
```
|
```
|
||||||
ws://localhost:3000/game
|
wss://localhost:3000/game
|
||||||
```
|
```
|
||||||
|
|
||||||
### 连接参数
|
### 连接参数
|
||||||
|
|||||||
@@ -68,4 +68,149 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
|||||||
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
|
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
|
||||||
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
|
||||||
3. 协议统一:
|
3. 协议统一:
|
||||||
- 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。
|
- 不再需要处理 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
260
docs/systems/zulip/quick_tests/test-get-messages.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* 测试通过 WebSocket 接收 Zulip 消息
|
||||||
|
*
|
||||||
|
* 设计理念:
|
||||||
|
* - Zulip API Key 永不下发到客户端
|
||||||
|
* - 所有 Zulip 交互通过游戏服务器的 WebSocket 进行
|
||||||
|
* - 客户端只接收 chat_render 消息,不直接调用 Zulip API
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 登录游戏服务器获取 JWT Token
|
||||||
|
* 2. 通过 WebSocket 连接游戏服务器
|
||||||
|
* 3. 在当前地图 (Whale Port) 接收消息
|
||||||
|
* 4. 切换到 Pumpkin Valley 接收消息
|
||||||
|
* 5. 统计接收到的消息数量
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node docs/systems/zulip/quick_tests/test-get-messages.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
const io = require('socket.io-client');
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const GAME_SERVER = 'http://localhost:3000';
|
||||||
|
const TEST_USER = {
|
||||||
|
username: 'angtest123',
|
||||||
|
password: 'angtest123',
|
||||||
|
email: 'angjustinl@163.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试配置
|
||||||
|
const TEST_CONFIG = {
|
||||||
|
whalePortWaitTime: 10000, // 在 Whale Port 等待 10 秒
|
||||||
|
pumpkinValleyWaitTime: 10000, // 在 Pumpkin Valley 等待 10 秒
|
||||||
|
totalTimeout: 30000 // 总超时时间 30 秒
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录游戏服务器获取用户信息
|
||||||
|
*/
|
||||||
|
async function loginToGameServer() {
|
||||||
|
console.log('📝 步骤 1: 登录游戏服务器');
|
||||||
|
console.log(` 用户名: ${TEST_USER.username}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||||
|
identifier: TEST_USER.username,
|
||||||
|
password: TEST_USER.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 登录成功');
|
||||||
|
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||||
|
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||||
|
return {
|
||||||
|
userId: response.data.data.user.id,
|
||||||
|
username: response.data.data.user.username,
|
||||||
|
email: response.data.data.user.email,
|
||||||
|
token: response.data.data.access_token
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 WebSocket 接收消息
|
||||||
|
*/
|
||||||
|
async function receiveMessagesViaWebSocket(userInfo) {
|
||||||
|
console.log('\n📡 步骤 2: 通过 WebSocket 连接并接收消息');
|
||||||
|
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = io(`${GAME_SERVER}/game`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
const receivedMessages = {
|
||||||
|
whalePort: [],
|
||||||
|
pumpkinValley: []
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentMap = 'whale_port';
|
||||||
|
let testPhase = 0; // 0: 连接中, 1: Whale Port, 2: Pumpkin Valley, 3: 完成
|
||||||
|
|
||||||
|
// 连接成功
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('✅ WebSocket 连接成功');
|
||||||
|
|
||||||
|
// 发送登录消息
|
||||||
|
const loginMessage = {
|
||||||
|
type: 'login',
|
||||||
|
token: userInfo.token
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 发送登录消息...');
|
||||||
|
socket.emit('login', loginMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录成功
|
||||||
|
socket.on('login_success', (data) => {
|
||||||
|
console.log('✅ 登录成功');
|
||||||
|
console.log(` 会话ID: ${data.sessionId}`);
|
||||||
|
console.log(` 用户ID: ${data.userId}`);
|
||||||
|
console.log(` 当前地图: ${data.currentMap}`);
|
||||||
|
|
||||||
|
testPhase = 1;
|
||||||
|
currentMap = data.currentMap || 'whale_port';
|
||||||
|
|
||||||
|
console.log(`\n📬 步骤 3: 在 Whale Port 接收消息 (等待 ${TEST_CONFIG.whalePortWaitTime / 1000} 秒)`);
|
||||||
|
console.log(' 💡 提示: 请在 Zulip 的 "Whale Port" Stream 发送测试消息');
|
||||||
|
|
||||||
|
// 在 Whale Port 等待一段时间
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(`\n📊 Whale Port 接收到 ${receivedMessages.whalePort.length} 条消息`);
|
||||||
|
|
||||||
|
// 切换到 Pumpkin Valley
|
||||||
|
console.log(`\n📤 步骤 4: 切换到 Pumpkin Valley`);
|
||||||
|
const positionUpdate = {
|
||||||
|
t: 'position',
|
||||||
|
x: 150,
|
||||||
|
y: 400,
|
||||||
|
mapId: 'pumpkin_valley'
|
||||||
|
};
|
||||||
|
socket.emit('position_update', positionUpdate);
|
||||||
|
|
||||||
|
testPhase = 2;
|
||||||
|
currentMap = 'pumpkin_valley';
|
||||||
|
|
||||||
|
console.log(`\n📬 步骤 5: 在 Pumpkin Valley 接收消息 (等待 ${TEST_CONFIG.pumpkinValleyWaitTime / 1000} 秒)`);
|
||||||
|
console.log(' 💡 提示: 请在 Zulip 的 "Pumpkin Valley" Stream 发送测试消息');
|
||||||
|
|
||||||
|
// 在 Pumpkin Valley 等待一段时间
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(`\n📊 Pumpkin Valley 接收到 ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||||
|
|
||||||
|
testPhase = 3;
|
||||||
|
console.log('\n📊 测试完成,断开连接...');
|
||||||
|
socket.disconnect();
|
||||||
|
}, TEST_CONFIG.pumpkinValleyWaitTime);
|
||||||
|
}, TEST_CONFIG.whalePortWaitTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 接收到消息 (chat_render)
|
||||||
|
socket.on('chat_render', (data) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString('zh-CN');
|
||||||
|
|
||||||
|
console.log(`\n📨 [${timestamp}] 收到消息:`);
|
||||||
|
console.log(` ├─ 发送者: ${data.from}`);
|
||||||
|
console.log(` ├─ 内容: ${data.txt}`);
|
||||||
|
console.log(` ├─ Stream: ${data.stream || '未知'}`);
|
||||||
|
console.log(` ├─ Topic: ${data.topic || '未知'}`);
|
||||||
|
console.log(` └─ 当前地图: ${currentMap}`);
|
||||||
|
|
||||||
|
// 记录消息
|
||||||
|
const message = {
|
||||||
|
from: data.from,
|
||||||
|
content: data.txt,
|
||||||
|
stream: data.stream,
|
||||||
|
topic: data.topic,
|
||||||
|
timestamp: new Date(),
|
||||||
|
map: currentMap
|
||||||
|
};
|
||||||
|
|
||||||
|
if (testPhase === 1) {
|
||||||
|
receivedMessages.whalePort.push(message);
|
||||||
|
} else if (testPhase === 2) {
|
||||||
|
receivedMessages.pumpkinValley.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接断开
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('\n🔌 WebSocket 连接已关闭');
|
||||||
|
resolve(receivedMessages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接错误
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('❌ 连接错误:', error.message);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 总超时保护
|
||||||
|
setTimeout(() => {
|
||||||
|
if (socket.connected) {
|
||||||
|
console.log('\n⏰ 测试超时,关闭连接');
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
}, TEST_CONFIG.totalTimeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主测试流程
|
||||||
|
*/
|
||||||
|
async function runTest() {
|
||||||
|
console.log('🚀 开始测试通过 WebSocket 接收 Zulip 消息');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('📋 设计理念: Zulip API Key 永不下发到客户端');
|
||||||
|
console.log('📋 所有消息通过游戏服务器的 WebSocket (chat_render) 接收');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1: 登录游戏服务器
|
||||||
|
const userInfo = await loginToGameServer();
|
||||||
|
|
||||||
|
// 步骤2-5: 通过 WebSocket 接收消息
|
||||||
|
const receivedMessages = await receiveMessagesViaWebSocket(userInfo);
|
||||||
|
|
||||||
|
// 步骤6: 统计信息
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('📊 测试结果汇总');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`✅ Whale Port: ${receivedMessages.whalePort.length} 条消息`);
|
||||||
|
console.log(`✅ Pumpkin Valley: ${receivedMessages.pumpkinValley.length} 条消息`);
|
||||||
|
console.log(`📝 总计: ${receivedMessages.whalePort.length + receivedMessages.pumpkinValley.length} 条消息`);
|
||||||
|
|
||||||
|
// 显示详细消息列表
|
||||||
|
if (receivedMessages.whalePort.length > 0) {
|
||||||
|
console.log('\n📬 Whale Port 消息列表:');
|
||||||
|
receivedMessages.whalePort.forEach((msg, index) => {
|
||||||
|
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivedMessages.pumpkinValley.length > 0) {
|
||||||
|
console.log('\n📬 Pumpkin Valley 消息列表:');
|
||||||
|
receivedMessages.pumpkinValley.forEach((msg, index) => {
|
||||||
|
console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
console.log('\n🎉 测试完成!');
|
||||||
|
console.log('💡 提示: 客户端通过 WebSocket 接收消息,无需直接访问 Zulip API');
|
||||||
|
console.log('💡 提示: 访问 https://zulip.xinghangee.icu 查看完整消息历史');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 测试失败:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行测试
|
||||||
|
runTest();
|
||||||
@@ -1,15 +1,102 @@
|
|||||||
const zulip = require('zulip-js');
|
const zulip = require('zulip-js');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
async function listSubscriptions() {
|
// 配置
|
||||||
console.log('🔧 检查用户订阅的 Streams...');
|
const GAME_SERVER = 'http://localhost:3000';
|
||||||
|
const TEST_USER = {
|
||||||
const config = {
|
username: 'angtest123',
|
||||||
username: 'angjustinl@mail.angforever.top',
|
password: 'angtest123',
|
||||||
apiKey: 'lCPWC...pqNfGF8',
|
email: 'angjustinl@163.com'
|
||||||
realm: 'https://zulip.xinghangee.icu/'
|
};
|
||||||
};
|
|
||||||
|
/**
|
||||||
|
* 登录游戏服务器获取用户信息
|
||||||
|
*/
|
||||||
|
async function loginToGameServer() {
|
||||||
|
console.log('📝 步骤 1: 登录游戏服务器');
|
||||||
|
console.log(` 用户名: ${TEST_USER.username}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||||
|
identifier: TEST_USER.username,
|
||||||
|
password: TEST_USER.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 登录成功');
|
||||||
|
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||||
|
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||||
|
return {
|
||||||
|
userId: response.data.data.user.id,
|
||||||
|
username: response.data.data.user.username,
|
||||||
|
email: response.data.data.user.email,
|
||||||
|
token: response.data.data.access_token
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用密码获取 Zulip API Key
|
||||||
|
*/
|
||||||
|
async function getZulipApiKey(email, password) {
|
||||||
|
console.log('\n📝 步骤 2: 获取 Zulip API Key');
|
||||||
|
console.log(` 邮箱: ${email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zulip API 使用 Basic Auth 和 form data
|
||||||
|
const response = await axios.post(
|
||||||
|
'https://zulip.xinghangee.icu/api/v1/fetch_api_key',
|
||||||
|
`username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.result === 'success') {
|
||||||
|
console.log('✅ 成功获取 API Key');
|
||||||
|
console.log(` API Key: ${response.data.api_key.substring(0, 10)}...`);
|
||||||
|
console.log(` 用户ID: ${response.data.user_id}`);
|
||||||
|
return {
|
||||||
|
apiKey: response.data.api_key,
|
||||||
|
email: response.data.email,
|
||||||
|
userId: response.data.user_id
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '获取 API Key 失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 获取 API Key 失败:', error.response?.data?.msg || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSubscriptions() {
|
||||||
|
console.log('🚀 开始测试用户订阅的 Streams');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1: 登录游戏服务器
|
||||||
|
const userInfo = await loginToGameServer();
|
||||||
|
|
||||||
|
// 步骤2: 获取 Zulip API Key
|
||||||
|
const zulipAuth = await getZulipApiKey(userInfo.email, TEST_USER.password);
|
||||||
|
|
||||||
|
console.log('\n📝 步骤 3: 检查用户订阅的 Streams');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
username: zulipAuth.email,
|
||||||
|
apiKey: zulipAuth.apiKey,
|
||||||
|
realm: 'https://zulip.xinghangee.icu/'
|
||||||
|
};
|
||||||
|
|
||||||
const client = await zulip(config);
|
const client = await zulip(config);
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
@@ -29,15 +116,15 @@ async function listSubscriptions() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 检查是否有 "Novice Village"
|
// 检查是否有 "Novice Village"
|
||||||
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village');
|
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley');
|
||||||
if (noviceVillage) {
|
if (noviceVillage) {
|
||||||
console.log('\n✅ "Novice Village" Stream 已存在!');
|
console.log('\n✅ "Pumpkin Valley" Stream 已存在!');
|
||||||
|
|
||||||
// 测试发送消息
|
// 测试发送消息
|
||||||
console.log('\n📤 测试发送消息...');
|
console.log('\n📤 测试发送消息...');
|
||||||
const result = await client.messages.send({
|
const result = await client.messages.send({
|
||||||
type: 'stream',
|
type: 'stream',
|
||||||
to: 'Novice Village',
|
to: 'Pumpkin Valley',
|
||||||
subject: 'General',
|
subject: 'General',
|
||||||
content: '测试消息:系统集成测试成功 🎮'
|
content: '测试消息:系统集成测试成功 🎮'
|
||||||
});
|
});
|
||||||
@@ -48,7 +135,7 @@ async function listSubscriptions() {
|
|||||||
console.log('❌ 消息发送失败:', result.msg);
|
console.log('❌ 消息发送失败:', result.msg);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('\n⚠️ "Novice Village" Stream 不存在');
|
console.log('\n⚠️ "Pumpkin Valley" Stream 不存在');
|
||||||
console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建');
|
console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建');
|
||||||
|
|
||||||
// 尝试发送到第一个可用的 Stream
|
// 尝试发送到第一个可用的 Stream
|
||||||
@@ -79,7 +166,9 @@ async function listSubscriptions() {
|
|||||||
if (error.response) {
|
if (error.response) {
|
||||||
console.error('响应数据:', error.response.data);
|
console.error('响应数据:', error.response.data);
|
||||||
}
|
}
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 运行测试
|
||||||
listSubscriptions();
|
listSubscriptions();
|
||||||
|
|||||||
232
docs/systems/zulip/quick_tests/test-registered-user.js
Normal file
232
docs/systems/zulip/quick_tests/test-registered-user.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* 测试新注册用户的Zulip账号功能
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 验证新注册用户可以通过游戏服务器登录
|
||||||
|
* 2. 验证Zulip账号已正确创建和关联
|
||||||
|
* 3. 验证用户可以通过WebSocket发送消息到Zulip
|
||||||
|
* 4. 验证用户可以接收来自Zulip的消息
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node docs/systems/zulip/quick_tests/test-registered-user.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const io = require('socket.io-client');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const GAME_SERVER = 'http://localhost:3000';
|
||||||
|
const TEST_USER = {
|
||||||
|
username: 'angtest123',
|
||||||
|
password: 'angtest123',
|
||||||
|
email: 'angjustinl@163.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤1: 登录游戏服务器获取token
|
||||||
|
*/
|
||||||
|
async function loginToGameServer() {
|
||||||
|
console.log('📝 步骤 1: 登录游戏服务器');
|
||||||
|
console.log(` 用户名: ${TEST_USER.username}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||||
|
identifier: TEST_USER.username,
|
||||||
|
password: TEST_USER.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 登录成功');
|
||||||
|
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||||
|
console.log(` 昵称: ${response.data.data.user.nickname}`);
|
||||||
|
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||||
|
console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`);
|
||||||
|
return {
|
||||||
|
userId: response.data.data.user.id,
|
||||||
|
username: response.data.data.user.username,
|
||||||
|
token: response.data.data.access_token
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤2: 通过WebSocket连接并测试Zulip集成
|
||||||
|
*/
|
||||||
|
async function testZulipIntegration(userInfo) {
|
||||||
|
console.log('\n📡 步骤 2: 测试 Zulip 集成');
|
||||||
|
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = io(`${GAME_SERVER}/game`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
timeout: 20000
|
||||||
|
});
|
||||||
|
|
||||||
|
let testStep = 0;
|
||||||
|
let testResults = {
|
||||||
|
connected: false,
|
||||||
|
loggedIn: false,
|
||||||
|
messageSent: false,
|
||||||
|
messageReceived: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接成功
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('✅ WebSocket 连接成功');
|
||||||
|
testResults.connected = true;
|
||||||
|
testStep = 1;
|
||||||
|
|
||||||
|
// 发送登录消息
|
||||||
|
const loginMessage = {
|
||||||
|
type: 'login',
|
||||||
|
token: userInfo.token
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 发送登录消息...');
|
||||||
|
socket.emit('login', loginMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录成功
|
||||||
|
socket.on('login_success', (data) => {
|
||||||
|
console.log('✅ 登录成功');
|
||||||
|
console.log(` 会话ID: ${data.sessionId}`);
|
||||||
|
console.log(` 用户ID: ${data.userId}`);
|
||||||
|
console.log(` 用户名: ${data.username}`);
|
||||||
|
console.log(` 当前地图: ${data.currentMap}`);
|
||||||
|
testResults.loggedIn = true;
|
||||||
|
testStep = 2;
|
||||||
|
|
||||||
|
// 等待Zulip客户端初始化
|
||||||
|
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||||
|
setTimeout(() => {
|
||||||
|
const chatMessage = {
|
||||||
|
t: 'chat',
|
||||||
|
content: `🎮 【注册用户测试】来自 ${userInfo.username} 的消息!\n` +
|
||||||
|
`时间: ${new Date().toLocaleString()}\n` +
|
||||||
|
`这是通过新注册账号发送的测试消息。`,
|
||||||
|
scope: 'local'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 发送测试消息到 Zulip...');
|
||||||
|
console.log(` 内容: ${chatMessage.content.split('\n')[0]}`);
|
||||||
|
socket.emit('chat', chatMessage);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 消息发送成功
|
||||||
|
socket.on('chat_sent', (data) => {
|
||||||
|
console.log('✅ 消息发送成功');
|
||||||
|
console.log(` 消息ID: ${data.id || '未知'}`);
|
||||||
|
testResults.messageSent = true;
|
||||||
|
testStep = 3;
|
||||||
|
|
||||||
|
// 等待一段时间接收消息
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('\n📊 测试完成,断开连接...');
|
||||||
|
socket.disconnect();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 接收到消息
|
||||||
|
socket.on('chat_render', (data) => {
|
||||||
|
console.log('📨 收到来自 Zulip 的消息:');
|
||||||
|
console.log(` 发送者: ${data.from}`);
|
||||||
|
console.log(` 内容: ${data.txt}`);
|
||||||
|
console.log(` Stream: ${data.stream || '未知'}`);
|
||||||
|
console.log(` Topic: ${data.topic || '未知'}`);
|
||||||
|
testResults.messageReceived = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.error('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接断开
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('\n🔌 WebSocket 连接已关闭');
|
||||||
|
resolve(testResults);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接错误
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('❌ 连接错误:', error.message);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 超时保护
|
||||||
|
setTimeout(() => {
|
||||||
|
if (socket.connected) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印测试结果
|
||||||
|
*/
|
||||||
|
function printTestResults(results) {
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('📊 测试结果汇总');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{ name: 'WebSocket 连接', passed: results.connected },
|
||||||
|
{ name: '游戏服务器登录', passed: results.loggedIn },
|
||||||
|
{ name: '发送消息到 Zulip', passed: results.messageSent },
|
||||||
|
{ name: '接收 Zulip 消息', passed: results.messageReceived }
|
||||||
|
];
|
||||||
|
|
||||||
|
checks.forEach(check => {
|
||||||
|
const icon = check.passed ? '✅' : '❌';
|
||||||
|
console.log(`${icon} ${check.name}: ${check.passed ? '通过' : '失败'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const passedCount = checks.filter(c => c.passed).length;
|
||||||
|
const totalCount = checks.length;
|
||||||
|
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`总计: ${passedCount}/${totalCount} 项测试通过`);
|
||||||
|
|
||||||
|
if (passedCount === totalCount) {
|
||||||
|
console.log('\n🎉 所有测试通过!Zulip账号创建和集成功能正常!');
|
||||||
|
console.log('💡 提示: 请访问 https://zulip.xinghangee.icu/ 查看发送的消息');
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ 部分测试失败,请检查日志');
|
||||||
|
}
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主测试流程
|
||||||
|
*/
|
||||||
|
async function runTest() {
|
||||||
|
console.log('🚀 开始测试新注册用户的 Zulip 集成功能');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 步骤1: 登录
|
||||||
|
const userInfo = await loginToGameServer();
|
||||||
|
|
||||||
|
// 步骤2: 测试Zulip集成
|
||||||
|
const results = await testZulipIntegration(userInfo);
|
||||||
|
|
||||||
|
// 打印结果
|
||||||
|
printTestResults(results);
|
||||||
|
|
||||||
|
process.exit(results.connected && results.loggedIn && results.messageSent ? 0 : 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 测试失败:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行测试
|
||||||
|
runTest();
|
||||||
@@ -1,127 +1,183 @@
|
|||||||
const io = require('socket.io-client');
|
const io = require('socket.io-client');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const GAME_SERVER = 'http://localhost:3000';
|
||||||
|
const TEST_USER = {
|
||||||
|
username: 'angtest123',
|
||||||
|
password: 'angtest123',
|
||||||
|
email: 'angjustinl@163.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录游戏服务器获取token
|
||||||
|
*/
|
||||||
|
async function loginToGameServer() {
|
||||||
|
console.log('📝 步骤 1: 登录游戏服务器');
|
||||||
|
console.log(` 用户名: ${TEST_USER.username}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${GAME_SERVER}/auth/login`, {
|
||||||
|
identifier: TEST_USER.username,
|
||||||
|
password: TEST_USER.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('✅ 登录成功');
|
||||||
|
console.log(` 用户ID: ${response.data.data.user.id}`);
|
||||||
|
console.log(` 昵称: ${response.data.data.user.nickname}`);
|
||||||
|
console.log(` 邮箱: ${response.data.data.user.email}`);
|
||||||
|
console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`);
|
||||||
|
return {
|
||||||
|
userId: response.data.data.user.id,
|
||||||
|
username: response.data.data.user.username,
|
||||||
|
token: response.data.data.access_token
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 登录失败:', error.response?.data?.message || error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 使用用户 API Key 测试 Zulip 集成
|
// 使用用户 API Key 测试 Zulip 集成
|
||||||
async function testWithUserApiKey() {
|
async function testWithUserApiKey() {
|
||||||
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
|
console.log('🚀 开始测试用户 API Key 的 Zulip 集成');
|
||||||
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
|
console.log('='.repeat(60));
|
||||||
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
|
|
||||||
console.log('📡 游戏服务器: http://localhost:3000/game');
|
|
||||||
|
|
||||||
const socket = io('http://localhost:3000/game', {
|
try {
|
||||||
transports: ['websocket'],
|
// 登录获取 token
|
||||||
timeout: 20000
|
const userInfo = await loginToGameServer();
|
||||||
});
|
|
||||||
|
|
||||||
let testStep = 0;
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
console.log('✅ WebSocket 连接成功');
|
|
||||||
testStep = 1;
|
|
||||||
|
|
||||||
// 使用包含用户 API Key 的 token
|
console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成');
|
||||||
const loginMessage = {
|
console.log(` 连接到: ${GAME_SERVER}/game`);
|
||||||
type: 'login',
|
|
||||||
token: 'lCPWCPfGh7...fGF8_user_token'
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
|
||||||
socket.emit('login', loginMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('login_success', (data) => {
|
const socket = io(`${GAME_SERVER}/game`, {
|
||||||
console.log('✅ 步骤 1 完成: 登录成功');
|
transports: ['websocket'],
|
||||||
console.log(' 会话ID:', data.sessionId);
|
timeout: 20000
|
||||||
console.log(' 用户ID:', data.userId);
|
});
|
||||||
console.log(' 用户名:', data.username);
|
|
||||||
console.log(' 当前地图:', data.currentMap);
|
|
||||||
testStep = 2;
|
|
||||||
|
|
||||||
// 等待 Zulip 客户端初始化
|
let testStep = 0;
|
||||||
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
|
||||||
setTimeout(() => {
|
socket.on('connect', () => {
|
||||||
const chatMessage = {
|
console.log('✅ WebSocket 连接成功');
|
||||||
t: 'chat',
|
testStep = 1;
|
||||||
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
|
||||||
'时间: ' + new Date().toLocaleString() + '\\n' +
|
// 使用真实的 JWT token
|
||||||
'使用用户 API Key 发送此消息。',
|
const loginMessage = {
|
||||||
scope: 'local'
|
type: 'login',
|
||||||
|
token: userInfo.token
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
console.log('📤 步骤 3: 发送登录消息(使用 JWT Token)');
|
||||||
console.log(' 目标 Stream: Whale Port');
|
socket.emit('login', loginMessage);
|
||||||
socket.emit('chat', chatMessage);
|
});
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('chat_sent', (data) => {
|
socket.on('login_success', (data) => {
|
||||||
console.log('✅ 步骤 2 完成: 消息发送成功');
|
console.log('✅ 步骤 3 完成: 登录成功');
|
||||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
console.log(' 会话ID:', data.sessionId);
|
||||||
|
console.log(' 用户ID:', data.userId);
|
||||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
console.log(' 用户名:', data.username);
|
||||||
if (testStep === 2) {
|
console.log(' 当前地图:', data.currentMap);
|
||||||
testStep = 3;
|
testStep = 2;
|
||||||
|
|
||||||
|
// 等待 Zulip 客户端初始化
|
||||||
|
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 先切换到 Pumpkin Valley 地图
|
const chatMessage = {
|
||||||
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
t: 'chat',
|
||||||
const positionUpdate = {
|
content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` +
|
||||||
t: 'position',
|
`时间: ${new Date().toLocaleString()}\n` +
|
||||||
x: 150,
|
`使用真实 API Key 发送此消息。`,
|
||||||
y: 400,
|
scope: 'local'
|
||||||
mapId: 'pumpkin_valley'
|
|
||||||
};
|
};
|
||||||
socket.emit('position_update', positionUpdate);
|
|
||||||
|
|
||||||
// 等待位置更新后发送消息
|
console.log('📤 步骤 4: 发送消息到 Zulip(使用真实 API Key)');
|
||||||
|
console.log(' 目标 Stream: Whale Port');
|
||||||
|
socket.emit('chat', chatMessage);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chat_sent', (data) => {
|
||||||
|
console.log('✅ 步骤 4 完成: 消息发送成功');
|
||||||
|
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||||
|
if (testStep === 2) {
|
||||||
|
testStep = 3;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const chatMessage2 = {
|
// 先切换到 Pumpkin Valley 地图
|
||||||
t: 'chat',
|
console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图');
|
||||||
content: '🎃 在南瓜谷发送的测试消息!',
|
const positionUpdate = {
|
||||||
scope: 'local'
|
t: 'position',
|
||||||
|
x: 150,
|
||||||
|
y: 400,
|
||||||
|
mapId: 'pumpkin_valley'
|
||||||
};
|
};
|
||||||
|
socket.emit('position_update', positionUpdate);
|
||||||
|
|
||||||
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
// 等待位置更新后发送消息
|
||||||
socket.emit('chat', chatMessage2);
|
setTimeout(() => {
|
||||||
}, 1000);
|
const chatMessage2 = {
|
||||||
}, 2000);
|
t: 'chat',
|
||||||
}
|
content: '🎃 在南瓜谷发送的测试消息!',
|
||||||
});
|
scope: 'local'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 步骤 6: 在 Pumpkin Valley 发送消息');
|
||||||
|
socket.emit('chat', chatMessage2);
|
||||||
|
}, 1000);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('chat_render', (data) => {
|
socket.on('chat_render', (data) => {
|
||||||
console.log('📨 收到来自 Zulip 的消息:');
|
console.log('\n📨 收到来自 Zulip 的消息:');
|
||||||
console.log(' 发送者:', data.from);
|
console.log(' 发送者:', data.from);
|
||||||
console.log(' 内容:', data.txt);
|
console.log(' 内容:', data.txt);
|
||||||
console.log(' Stream:', data.stream || '未知');
|
console.log(' Stream:', data.stream || '未知');
|
||||||
console.log(' Topic:', data.topic || '未知');
|
console.log(' Topic:', data.topic || '未知');
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
socket.on('error', (error) => {
|
||||||
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log('🔌 WebSocket 连接已关闭');
|
console.log('\n🔌 WebSocket 连接已关闭');
|
||||||
console.log('');
|
console.log('\n' + '='.repeat(60));
|
||||||
console.log('📊 测试结果:');
|
console.log('📊 测试结果汇总');
|
||||||
console.log(' 完成步骤:', testStep, '/ 4');
|
console.log('='.repeat(60));
|
||||||
if (testStep >= 3) {
|
console.log(' 完成步骤:', testStep, '/ 3');
|
||||||
console.log(' ✅ 核心功能正常!');
|
if (testStep >= 3) {
|
||||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
console.log(' ✅ 核心功能正常!');
|
||||||
}
|
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||||
process.exit(0);
|
} else {
|
||||||
});
|
console.log(' ⚠️ 部分测试未完成');
|
||||||
|
}
|
||||||
socket.on('connect_error', (error) => {
|
console.log('='.repeat(60));
|
||||||
console.error('❌ 连接错误:', error.message);
|
process.exit(testStep >= 3 ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('❌ 连接错误:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 20秒后自动关闭(给足够时间完成测试)
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('\n⏰ 测试时间到,关闭连接');
|
||||||
|
socket.disconnect();
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 测试失败:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
}
|
||||||
|
|
||||||
// 20秒后自动关闭(给足够时间完成测试)
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('⏰ 测试时间到,关闭连接');
|
|
||||||
socket.disconnect();
|
|
||||||
}, 20000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔧 准备测试环境...');
|
// 运行测试
|
||||||
testWithUserApiKey().catch(console.error);
|
testWithUserApiKey();
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@nestjs/common": "^11.1.9",
|
"@nestjs/common": "^11.1.9",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.9",
|
"@nestjs/core": "^11.1.9",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/platform-express": "^10.4.20",
|
"@nestjs/platform-express": "^10.4.20",
|
||||||
"@nestjs/platform-socket.io": "^10.4.20",
|
"@nestjs/platform-socket.io": "^10.4.20",
|
||||||
"@nestjs/schedule": "^4.1.2",
|
"@nestjs/schedule": "^4.1.2",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
"@nestjs/testing": "^10.4.20",
|
"@nestjs/testing": "^10.4.20",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.27",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* - 用户登录、注册、密码管理
|
* - 用户登录、注册、密码管理
|
||||||
* - GitHub OAuth集成
|
* - GitHub OAuth集成
|
||||||
* - 邮箱验证功能
|
* - 邮箱验证功能
|
||||||
|
* - JWT令牌管理和验证
|
||||||
*
|
*
|
||||||
* @author kiro-ai
|
* @author kiro-ai
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
@@ -13,14 +14,41 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { LoginController } from './controllers/login.controller';
|
import { LoginController } from './controllers/login.controller';
|
||||||
import { LoginService } from './services/login.service';
|
import { LoginService } from './services/login.service';
|
||||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
|
||||||
|
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||||
|
import { UsersModule } from '../../core/db/users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LoginCoreModule],
|
imports: [
|
||||||
|
LoginCoreModule,
|
||||||
|
ZulipCoreModule,
|
||||||
|
ZulipAccountsModule.forRoot(),
|
||||||
|
UsersModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const expiresIn = configService.get<string>('JWT_EXPIRES_IN', '7d');
|
||||||
|
return {
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: expiresIn as any, // JWT库支持字符串格式如 '7d'
|
||||||
|
issuer: 'whale-town',
|
||||||
|
audience: 'whale-town-users',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
controllers: [LoginController],
|
controllers: [LoginController],
|
||||||
providers: [LoginService],
|
providers: [
|
||||||
exports: [LoginService],
|
LoginService,
|
||||||
|
],
|
||||||
|
exports: [LoginService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
* - POST /auth/forgot-password - 发送密码重置验证码
|
* - POST /auth/forgot-password - 发送密码重置验证码
|
||||||
* - POST /auth/reset-password - 重置密码
|
* - POST /auth/reset-password - 重置密码
|
||||||
* - PUT /auth/change-password - 修改密码
|
* - PUT /auth/change-password - 修改密码
|
||||||
|
* - POST /auth/refresh-token - 刷新访问令牌
|
||||||
*
|
*
|
||||||
* @author moyin angjustinl
|
* @author moyin angjustinl
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
@@ -23,7 +24,7 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP
|
|||||||
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { LoginService, ApiResponse, LoginResponse } from '../services/login.service';
|
import { LoginService, ApiResponse, LoginResponse } from '../services/login.service';
|
||||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../dto/login.dto';
|
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto';
|
||||||
import {
|
import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
RegisterResponseDto,
|
RegisterResponseDto,
|
||||||
@@ -31,7 +32,8 @@ import {
|
|||||||
ForgotPasswordResponseDto,
|
ForgotPasswordResponseDto,
|
||||||
CommonResponseDto,
|
CommonResponseDto,
|
||||||
TestModeEmailVerificationResponseDto,
|
TestModeEmailVerificationResponseDto,
|
||||||
SuccessEmailVerificationResponseDto
|
SuccessEmailVerificationResponseDto,
|
||||||
|
RefreshTokenResponseDto
|
||||||
} from '../dto/login_response.dto';
|
} from '../dto/login_response.dto';
|
||||||
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
|
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
|
||||||
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
|
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
|
||||||
@@ -609,4 +611,107 @@ export class LoginController {
|
|||||||
message: '限流记录已清除'
|
message: '限流记录已清除'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新访问令牌
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 验证刷新令牌的有效性和格式
|
||||||
|
* 2. 检查用户状态是否正常
|
||||||
|
* 3. 生成新的JWT令牌对
|
||||||
|
* 4. 返回新的访问令牌和刷新令牌
|
||||||
|
*
|
||||||
|
* @param refreshTokenDto 刷新令牌数据
|
||||||
|
* @param res Express响应对象
|
||||||
|
* @returns 新的令牌对
|
||||||
|
*/
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '刷新访问令牌',
|
||||||
|
description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: RefreshTokenDto })
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '令牌刷新成功',
|
||||||
|
type: RefreshTokenResponseDto
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误'
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: '刷新令牌无效或已过期'
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 404,
|
||||||
|
description: '用户不存在或已被禁用'
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 429,
|
||||||
|
description: '刷新请求过于频繁'
|
||||||
|
})
|
||||||
|
@Throttle(ThrottlePresets.REFRESH_TOKEN)
|
||||||
|
@Timeout(TimeoutPresets.NORMAL)
|
||||||
|
@Post('refresh-token')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log('令牌刷新请求', {
|
||||||
|
operation: 'refreshToken',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.logger.log('令牌刷新成功', {
|
||||||
|
operation: 'refreshToken',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
res.status(HttpStatus.OK).json(result);
|
||||||
|
} else {
|
||||||
|
this.logger.warn('令牌刷新失败', {
|
||||||
|
operation: 'refreshToken',
|
||||||
|
error: result.message,
|
||||||
|
errorCode: result.error_code,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根据错误类型设置不同的状态码
|
||||||
|
if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) {
|
||||||
|
res.status(HttpStatus.UNAUTHORIZED).json(result);
|
||||||
|
} else if (result.message?.includes('用户不存在')) {
|
||||||
|
res.status(HttpStatus.NOT_FOUND).json(result);
|
||||||
|
} else {
|
||||||
|
res.status(HttpStatus.BAD_REQUEST).json(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('令牌刷新异常', {
|
||||||
|
operation: 'refreshToken',
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||||
|
success: false,
|
||||||
|
message: '服务器内部错误',
|
||||||
|
error_code: 'INTERNAL_SERVER_ERROR'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -424,4 +424,21 @@ export class SendLoginVerificationCodeDto {
|
|||||||
@IsNotEmpty({ message: '登录标识符不能为空' })
|
@IsNotEmpty({ message: '登录标识符不能为空' })
|
||||||
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||||
identifier: string;
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新令牌请求DTO
|
||||||
|
*/
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
/**
|
||||||
|
* 刷新令牌
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'JWT刷新令牌',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
minLength: 1
|
||||||
|
})
|
||||||
|
@IsString({ message: '刷新令牌必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||||
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
@@ -80,17 +80,28 @@ export class LoginResponseDataDto {
|
|||||||
user: UserInfoDto;
|
user: UserInfoDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '访问令牌',
|
description: 'JWT访问令牌',
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||||
})
|
})
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '刷新令牌',
|
description: 'JWT刷新令牌',
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||||
required: false
|
|
||||||
})
|
})
|
||||||
refresh_token?: string;
|
refresh_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '访问令牌过期时间(秒)',
|
||||||
|
example: 604800
|
||||||
|
})
|
||||||
|
expires_in: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '令牌类型',
|
||||||
|
example: 'Bearer'
|
||||||
|
})
|
||||||
|
token_type: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '是否为新用户',
|
description: '是否为新用户',
|
||||||
@@ -392,4 +403,64 @@ export class SuccessEmailVerificationResponseDto {
|
|||||||
required: false
|
required: false
|
||||||
})
|
})
|
||||||
error_code?: string;
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌刷新响应数据DTO
|
||||||
|
*/
|
||||||
|
export class RefreshTokenResponseDataDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'JWT访问令牌',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||||
|
})
|
||||||
|
access_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'JWT刷新令牌',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||||
|
})
|
||||||
|
refresh_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '访问令牌过期时间(秒)',
|
||||||
|
example: 604800
|
||||||
|
})
|
||||||
|
expires_in: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '令牌类型',
|
||||||
|
example: 'Bearer'
|
||||||
|
})
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌刷新响应DTO
|
||||||
|
*/
|
||||||
|
export class RefreshTokenResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '请求是否成功',
|
||||||
|
example: true
|
||||||
|
})
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '响应数据',
|
||||||
|
type: RefreshTokenResponseDataDto,
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
data?: RefreshTokenResponseDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '响应消息',
|
||||||
|
example: '令牌刷新成功'
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '错误代码',
|
||||||
|
example: 'TOKEN_REFRESH_FAILED',
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
error_code?: string;
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* 登录业务服务测试
|
* 登录业务服务测试
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试登录相关的业务逻辑
|
||||||
|
* - 测试JWT令牌生成和验证
|
||||||
|
* - 测试令牌刷新功能
|
||||||
|
* - 测试各种异常情况处理
|
||||||
|
*
|
||||||
|
* @author kiro-ai
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-06
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { LoginService } from './login.service';
|
import { LoginService } from './login.service';
|
||||||
import { LoginCoreService } from '../../../core/login_core/login_core.service';
|
import { LoginCoreService } from '../../../core/login_core/login_core.service';
|
||||||
|
import { UsersService } from '../../../core/db/users/users.service';
|
||||||
|
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
|
||||||
|
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
|
||||||
|
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
// Mock jwt module
|
||||||
|
jest.mock('jsonwebtoken', () => ({
|
||||||
|
sign: jest.fn(),
|
||||||
|
verify: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('LoginService', () => {
|
describe('LoginService', () => {
|
||||||
let service: LoginService;
|
let service: LoginService;
|
||||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||||
|
let jwtService: jest.Mocked<JwtService>;
|
||||||
|
let configService: jest.Mocked<ConfigService>;
|
||||||
|
let usersService: jest.Mocked<UsersService>;
|
||||||
|
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||||
|
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
|
||||||
|
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||||
|
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: BigInt(1),
|
id: BigInt(1),
|
||||||
@@ -26,7 +55,20 @@ describe('LoginService', () => {
|
|||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockJwtSecret = 'test_jwt_secret_key_for_testing_32chars';
|
||||||
|
const mockAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE2NDA5OTUyMDAsImlzcyI6IndoYWxlLXRvd24iLCJhdWQiOiJ3aGFsZS10b3duLXVzZXJzIn0.test';
|
||||||
|
const mockRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNjQwOTk1MjAwLCJpc3MiOiJ3aGFsZS10b3duIiwiYXVkIjoid2hhbGUtdG93bi11c2VycyJ9.test';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
// Mock environment variables for Zulip
|
||||||
|
const originalEnv = process.env;
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
ZULIP_SERVER_URL: 'https://test.zulipchat.com',
|
||||||
|
ZULIP_BOT_EMAIL: 'test-bot@test.zulipchat.com',
|
||||||
|
ZULIP_BOT_API_KEY: 'test_api_key_12345',
|
||||||
|
};
|
||||||
|
|
||||||
const mockLoginCoreService = {
|
const mockLoginCoreService = {
|
||||||
login: jest.fn(),
|
login: jest.fn(),
|
||||||
register: jest.fn(),
|
register: jest.fn(),
|
||||||
@@ -40,6 +82,36 @@ describe('LoginService', () => {
|
|||||||
verificationCodeLogin: jest.fn(),
|
verificationCodeLogin: jest.fn(),
|
||||||
sendLoginVerificationCode: jest.fn(),
|
sendLoginVerificationCode: jest.fn(),
|
||||||
debugVerificationCode: jest.fn(),
|
debugVerificationCode: jest.fn(),
|
||||||
|
deleteUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
signAsync: jest.fn(),
|
||||||
|
verifyAsync: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockZulipAccountService = {
|
||||||
|
initializeAdminClient: jest.fn(),
|
||||||
|
createZulipAccount: jest.fn(),
|
||||||
|
linkGameAccount: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockZulipAccountsRepository = {
|
||||||
|
findByGameUserId: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
deleteByGameUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockApiKeySecurityService = {
|
||||||
|
storeApiKey: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -49,11 +121,72 @@ describe('LoginService', () => {
|
|||||||
provide: LoginCoreService,
|
provide: LoginCoreService,
|
||||||
useValue: mockLoginCoreService,
|
useValue: mockLoginCoreService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: JwtService,
|
||||||
|
useValue: mockJwtService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'UsersService',
|
||||||
|
useValue: mockUsersService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ZulipAccountService,
|
||||||
|
useValue: mockZulipAccountService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'ZulipAccountsRepository',
|
||||||
|
useValue: mockZulipAccountsRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ApiKeySecurityService,
|
||||||
|
useValue: mockApiKeySecurityService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<LoginService>(LoginService);
|
service = module.get<LoginService>(LoginService);
|
||||||
loginCoreService = module.get(LoginCoreService);
|
loginCoreService = module.get(LoginCoreService);
|
||||||
|
jwtService = module.get(JwtService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
usersService = module.get('UsersService');
|
||||||
|
zulipAccountService = module.get(ZulipAccountService);
|
||||||
|
zulipAccountsRepository = module.get('ZulipAccountsRepository');
|
||||||
|
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||||
|
|
||||||
|
// Setup default config service mocks
|
||||||
|
configService.get.mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
const config = {
|
||||||
|
'JWT_SECRET': mockJwtSecret,
|
||||||
|
'JWT_EXPIRES_IN': '7d',
|
||||||
|
};
|
||||||
|
return config[key] || defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup default JWT service mocks
|
||||||
|
jwtService.signAsync.mockResolvedValue(mockAccessToken);
|
||||||
|
(jwt.sign as jest.Mock).mockReturnValue(mockRefreshToken);
|
||||||
|
|
||||||
|
// Setup default Zulip mocks
|
||||||
|
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
|
||||||
|
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
userId: 123,
|
||||||
|
email: 'test@example.com',
|
||||||
|
apiKey: 'mock_api_key'
|
||||||
|
});
|
||||||
|
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||||
|
zulipAccountsRepository.create.mockResolvedValue({} as any);
|
||||||
|
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Restore original environment variables
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -61,7 +194,7 @@ describe('LoginService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('should login successfully', async () => {
|
it('should login successfully and return JWT tokens', async () => {
|
||||||
loginCoreService.login.mockResolvedValue({
|
loginCoreService.login.mockResolvedValue({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
isNewUser: false
|
isNewUser: false
|
||||||
@@ -74,7 +207,40 @@ describe('LoginService', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.data?.user.username).toBe('testuser');
|
expect(result.data?.user.username).toBe('testuser');
|
||||||
expect(result.data?.access_token).toBeDefined();
|
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||||
|
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||||
|
expect(result.data?.expires_in).toBe(604800); // 7 days in seconds
|
||||||
|
expect(result.data?.token_type).toBe('Bearer');
|
||||||
|
expect(result.data?.is_new_user).toBe(false);
|
||||||
|
expect(result.message).toBe('登录成功');
|
||||||
|
|
||||||
|
// Verify JWT service was called correctly
|
||||||
|
expect(jwtService.signAsync).toHaveBeenCalledWith({
|
||||||
|
sub: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
role: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
type: 'access',
|
||||||
|
iat: expect.any(Number),
|
||||||
|
iss: 'whale-town',
|
||||||
|
aud: 'whale-town-users',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
sub: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
role: 1,
|
||||||
|
type: 'refresh',
|
||||||
|
iat: expect.any(Number),
|
||||||
|
iss: 'whale-town',
|
||||||
|
aud: 'whale-town-users',
|
||||||
|
},
|
||||||
|
mockJwtSecret,
|
||||||
|
{
|
||||||
|
expiresIn: '30d',
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle login failure', async () => {
|
it('should handle login failure', async () => {
|
||||||
@@ -87,16 +253,80 @@ describe('LoginService', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error_code).toBe('LOGIN_FAILED');
|
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||||
|
expect(result.message).toBe('用户名或密码错误');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JWT generation failure', async () => {
|
||||||
|
loginCoreService.login.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: false
|
||||||
|
});
|
||||||
|
|
||||||
|
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
|
||||||
|
|
||||||
|
const result = await service.login({
|
||||||
|
identifier: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||||
|
expect(result.message).toContain('JWT generation failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing JWT secret', async () => {
|
||||||
|
loginCoreService.login.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: false
|
||||||
|
});
|
||||||
|
|
||||||
|
configService.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'JWT_SECRET') return undefined;
|
||||||
|
if (key === 'JWT_EXPIRES_IN') return '7d';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.login({
|
||||||
|
identifier: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||||
|
expect(result.message).toContain('JWT_SECRET未配置');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
it('should register successfully', async () => {
|
it('should register successfully with JWT tokens', async () => {
|
||||||
loginCoreService.register.mockResolvedValue({
|
loginCoreService.register.mockResolvedValue({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
isNewUser: true
|
isNewUser: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123',
|
||||||
|
nickname: '测试用户',
|
||||||
|
email: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe('testuser');
|
||||||
|
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||||
|
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||||
|
expect(result.data?.expires_in).toBe(604800);
|
||||||
|
expect(result.data?.token_type).toBe('Bearer');
|
||||||
|
expect(result.data?.is_new_user).toBe(true);
|
||||||
|
expect(result.message).toBe('注册成功,Zulip账号已同步创建');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register successfully without email', async () => {
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: { ...mockUser, email: null },
|
||||||
|
isNewUser: true
|
||||||
|
});
|
||||||
|
|
||||||
const result = await service.register({
|
const result = await service.register({
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
@@ -104,13 +334,323 @@ describe('LoginService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.data?.user.username).toBe('testuser');
|
expect(result.data?.message).toBe('注册成功');
|
||||||
expect(result.data?.is_new_user).toBe(true);
|
// Should not try to create Zulip account without email
|
||||||
|
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Zulip account creation failure and rollback', async () => {
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: true
|
||||||
|
});
|
||||||
|
|
||||||
|
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'Zulip creation failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
loginCoreService.deleteUser.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123',
|
||||||
|
nickname: '测试用户',
|
||||||
|
email: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Zulip账号创建失败');
|
||||||
|
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle register failure', async () => {
|
||||||
|
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123',
|
||||||
|
nickname: '测试用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('REGISTER_FAILED');
|
||||||
|
expect(result.message).toBe('用户名已存在');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('verificationCodeLogin', () => {
|
describe('verifyToken', () => {
|
||||||
it('should login with verification code successfully', async () => {
|
const mockPayload = {
|
||||||
|
sub: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
role: 1,
|
||||||
|
type: 'access' as const,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
iss: 'whale-town',
|
||||||
|
aud: 'whale-town-users',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should verify access token successfully', async () => {
|
||||||
|
(jwt.verify as jest.Mock).mockReturnValue(mockPayload);
|
||||||
|
|
||||||
|
const result = await service.verifyToken(mockAccessToken, 'access');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockPayload);
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith(
|
||||||
|
mockAccessToken,
|
||||||
|
mockJwtSecret,
|
||||||
|
{
|
||||||
|
issuer: 'whale-town',
|
||||||
|
audience: 'whale-town-users',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify refresh token successfully', async () => {
|
||||||
|
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
|
||||||
|
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
|
||||||
|
|
||||||
|
const result = await service.verifyToken(mockRefreshToken, 'refresh');
|
||||||
|
|
||||||
|
expect(result).toEqual(refreshPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid token', async () => {
|
||||||
|
(jwt.verify as jest.Mock).mockImplementation(() => {
|
||||||
|
throw new Error('invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.verifyToken('invalid_token')).rejects.toThrow('令牌验证失败: invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for token type mismatch', async () => {
|
||||||
|
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
|
||||||
|
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
|
||||||
|
|
||||||
|
await expect(service.verifyToken(mockRefreshToken, 'access')).rejects.toThrow('令牌类型不匹配');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for incomplete payload', async () => {
|
||||||
|
const incompletePayload = { sub: '1', type: 'access' }; // missing username and role
|
||||||
|
(jwt.verify as jest.Mock).mockReturnValue(incompletePayload);
|
||||||
|
|
||||||
|
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('令牌载荷数据不完整');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when JWT secret is missing', async () => {
|
||||||
|
configService.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'JWT_SECRET') return undefined;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('JWT_SECRET未配置');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshAccessToken', () => {
|
||||||
|
const mockRefreshPayload = {
|
||||||
|
sub: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
role: 1,
|
||||||
|
type: 'refresh' as const,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
iss: 'whale-town',
|
||||||
|
aud: 'whale-town-users',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(jwt.verify as jest.Mock).mockReturnValue(mockRefreshPayload);
|
||||||
|
usersService.findOne.mockResolvedValue(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refresh access token successfully', async () => {
|
||||||
|
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||||
|
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||||
|
expect(result.data?.expires_in).toBe(604800);
|
||||||
|
expect(result.data?.token_type).toBe('Bearer');
|
||||||
|
expect(result.message).toBe('令牌刷新成功');
|
||||||
|
|
||||||
|
expect(jwt.verify).toHaveBeenCalledWith(
|
||||||
|
mockRefreshToken,
|
||||||
|
mockJwtSecret,
|
||||||
|
{
|
||||||
|
issuer: 'whale-town',
|
||||||
|
audience: 'whale-town-users',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(usersService.findOne).toHaveBeenCalledWith(BigInt(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid refresh token', async () => {
|
||||||
|
(jwt.verify as jest.Mock).mockImplementation(() => {
|
||||||
|
throw new Error('invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.refreshAccessToken('invalid_token');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||||
|
expect(result.message).toContain('invalid token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user not found', async () => {
|
||||||
|
usersService.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||||
|
expect(result.message).toBe('用户不存在或已被禁用');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user service error', async () => {
|
||||||
|
usersService.findOne.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||||
|
expect(result.message).toContain('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JWT generation error during refresh', async () => {
|
||||||
|
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
|
||||||
|
|
||||||
|
const result = await service.refreshAccessToken(mockRefreshToken);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
|
||||||
|
expect(result.message).toContain('JWT generation failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseExpirationTime', () => {
|
||||||
|
it('should parse seconds correctly', () => {
|
||||||
|
const result = (service as any).parseExpirationTime('30s');
|
||||||
|
expect(result).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse minutes correctly', () => {
|
||||||
|
const result = (service as any).parseExpirationTime('5m');
|
||||||
|
expect(result).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse hours correctly', () => {
|
||||||
|
const result = (service as any).parseExpirationTime('2h');
|
||||||
|
expect(result).toBe(7200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse days correctly', () => {
|
||||||
|
const result = (service as any).parseExpirationTime('7d');
|
||||||
|
expect(result).toBe(604800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse weeks correctly', () => {
|
||||||
|
const result = (service as any).parseExpirationTime('2w');
|
||||||
|
expect(result).toBe(1209600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default for invalid format', () => {
|
||||||
|
const result = (service as any).parseExpirationTime('invalid');
|
||||||
|
expect(result).toBe(604800); // 7 days default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTokenPair', () => {
|
||||||
|
it('should generate token pair successfully', async () => {
|
||||||
|
const result = await (service as any).generateTokenPair(mockUser);
|
||||||
|
|
||||||
|
expect(result.access_token).toBe(mockAccessToken);
|
||||||
|
expect(result.refresh_token).toBe(mockRefreshToken);
|
||||||
|
expect(result.expires_in).toBe(604800);
|
||||||
|
expect(result.token_type).toBe('Bearer');
|
||||||
|
|
||||||
|
expect(jwtService.signAsync).toHaveBeenCalledWith({
|
||||||
|
sub: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
role: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
type: 'access',
|
||||||
|
iat: expect.any(Number),
|
||||||
|
iss: 'whale-town',
|
||||||
|
aud: 'whale-town-users',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
sub: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
role: 1,
|
||||||
|
type: 'refresh',
|
||||||
|
iat: expect.any(Number),
|
||||||
|
iss: 'whale-town',
|
||||||
|
aud: 'whale-town-users',
|
||||||
|
},
|
||||||
|
mockJwtSecret,
|
||||||
|
{
|
||||||
|
expiresIn: '30d',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing JWT secret', async () => {
|
||||||
|
configService.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'JWT_SECRET') return undefined;
|
||||||
|
if (key === 'JWT_EXPIRES_IN') return '7d';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT_SECRET未配置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JWT service error', async () => {
|
||||||
|
jwtService.signAsync.mockRejectedValue(new Error('JWT service error'));
|
||||||
|
|
||||||
|
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT service error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUserInfo', () => {
|
||||||
|
it('should format user info correctly', () => {
|
||||||
|
const formattedUser = (service as any).formatUserInfo(mockUser);
|
||||||
|
|
||||||
|
expect(formattedUser).toEqual({
|
||||||
|
id: '1',
|
||||||
|
username: 'testuser',
|
||||||
|
nickname: '测试用户',
|
||||||
|
email: 'test@example.com',
|
||||||
|
phone: '+8613800138000',
|
||||||
|
avatar_url: null,
|
||||||
|
role: 1,
|
||||||
|
created_at: mockUser.created_at
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('other methods', () => {
|
||||||
|
it('should handle githubOAuth successfully', async () => {
|
||||||
|
loginCoreService.githubOAuth.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.githubOAuth({
|
||||||
|
github_id: '12345',
|
||||||
|
username: 'testuser',
|
||||||
|
nickname: '测试用户',
|
||||||
|
email: 'test@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||||
|
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||||
|
expect(result.data?.message).toBe('GitHub登录成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle verificationCodeLogin successfully', async () => {
|
||||||
loginCoreService.verificationCodeLogin.mockResolvedValue({
|
loginCoreService.verificationCodeLogin.mockResolvedValue({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
isNewUser: false
|
isNewUser: false
|
||||||
@@ -123,23 +663,74 @@ describe('LoginService', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.data?.user.email).toBe('test@example.com');
|
expect(result.data?.user.email).toBe('test@example.com');
|
||||||
|
expect(result.data?.access_token).toBe(mockAccessToken);
|
||||||
|
expect(result.data?.refresh_token).toBe(mockRefreshToken);
|
||||||
|
expect(result.data?.message).toBe('验证码登录成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle verification code login failure', async () => {
|
it('should handle sendPasswordResetCode in test mode', async () => {
|
||||||
loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误'));
|
loginCoreService.sendPasswordResetCode.mockResolvedValue({
|
||||||
|
code: '123456',
|
||||||
const result = await service.verificationCodeLogin({
|
isTestMode: true
|
||||||
identifier: 'test@example.com',
|
|
||||||
verificationCode: '999999'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
const result = await service.sendPasswordResetCode('test@example.com');
|
||||||
expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sendLoginVerificationCode', () => {
|
expect(result.success).toBe(false); // Test mode returns false
|
||||||
it('should send login verification code successfully', async () => {
|
expect(result.data?.verification_code).toBe('123456');
|
||||||
|
expect(result.data?.is_test_mode).toBe(true);
|
||||||
|
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle resetPassword successfully', async () => {
|
||||||
|
loginCoreService.resetPassword.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await service.resetPassword({
|
||||||
|
identifier: 'test@example.com',
|
||||||
|
verificationCode: '123456',
|
||||||
|
newPassword: 'newpassword123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('密码重置成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle changePassword successfully', async () => {
|
||||||
|
loginCoreService.changePassword.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await service.changePassword(
|
||||||
|
BigInt(1),
|
||||||
|
'oldpassword',
|
||||||
|
'newpassword123'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('密码修改成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sendEmailVerification in test mode', async () => {
|
||||||
|
loginCoreService.sendEmailVerification.mockResolvedValue({
|
||||||
|
code: '123456',
|
||||||
|
isTestMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.sendEmailVerification('test@example.com');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data?.verification_code).toBe('123456');
|
||||||
|
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle verifyEmailCode successfully', async () => {
|
||||||
|
loginCoreService.verifyEmailCode.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.verifyEmailCode('test@example.com', '123456');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('邮箱验证成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sendLoginVerificationCode successfully', async () => {
|
||||||
loginCoreService.sendLoginVerificationCode.mockResolvedValue({
|
loginCoreService.sendLoginVerificationCode.mockResolvedValue({
|
||||||
code: '123456',
|
code: '123456',
|
||||||
isTestMode: true
|
isTestMode: true
|
||||||
@@ -151,5 +742,22 @@ describe('LoginService', () => {
|
|||||||
expect(result.data?.verification_code).toBe('123456');
|
expect(result.data?.verification_code).toBe('123456');
|
||||||
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
expect(result.error_code).toBe('TEST_MODE_ONLY');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle debugVerificationCode successfully', async () => {
|
||||||
|
const mockDebugInfo = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '123456',
|
||||||
|
expiresAt: new Date(),
|
||||||
|
attempts: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo);
|
||||||
|
|
||||||
|
const result = await service.debugVerificationCode('test@example.com');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(mockDebugInfo);
|
||||||
|
expect(result.message).toBe('调试信息获取成功');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -16,9 +16,54 @@
|
|||||||
* @since 2025-12-17
|
* @since 2025-12-17
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service';
|
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service';
|
||||||
import { Users } from '../../../core/db/users/users.entity';
|
import { Users } from '../../../core/db/users/users.entity';
|
||||||
|
import { UsersService } from '../../../core/db/users/users.service';
|
||||||
|
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
|
||||||
|
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
|
||||||
|
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT载荷接口
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
/** 用户ID */
|
||||||
|
sub: string;
|
||||||
|
/** 用户名 */
|
||||||
|
username: string;
|
||||||
|
/** 用户角色 */
|
||||||
|
role: number;
|
||||||
|
/** 邮箱 */
|
||||||
|
email?: string;
|
||||||
|
/** 令牌类型 */
|
||||||
|
type: 'access' | 'refresh';
|
||||||
|
/** 签发时间 */
|
||||||
|
iat?: number;
|
||||||
|
/** 过期时间 */
|
||||||
|
exp?: number;
|
||||||
|
/** 签发者 */
|
||||||
|
iss?: string;
|
||||||
|
/** 受众 */
|
||||||
|
aud?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌对接口
|
||||||
|
*/
|
||||||
|
export interface TokenPair {
|
||||||
|
/** 访问令牌 */
|
||||||
|
access_token: string;
|
||||||
|
/** 刷新令牌 */
|
||||||
|
refresh_token: string;
|
||||||
|
/** 访问令牌过期时间(秒) */
|
||||||
|
expires_in: number;
|
||||||
|
/** 令牌类型 */
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录响应数据接口
|
* 登录响应数据接口
|
||||||
@@ -35,10 +80,14 @@ export interface LoginResponse {
|
|||||||
role: number;
|
role: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
};
|
};
|
||||||
/** 访问令牌(实际应用中应生成JWT) */
|
/** 访问令牌 */
|
||||||
access_token: string;
|
access_token: string;
|
||||||
/** 刷新令牌 */
|
/** 刷新令牌 */
|
||||||
refresh_token?: string;
|
refresh_token: string;
|
||||||
|
/** 访问令牌过期时间(秒) */
|
||||||
|
expires_in: number;
|
||||||
|
/** 令牌类型 */
|
||||||
|
token_type: string;
|
||||||
/** 是否为新用户 */
|
/** 是否为新用户 */
|
||||||
is_new_user?: boolean;
|
is_new_user?: boolean;
|
||||||
/** 消息 */
|
/** 消息 */
|
||||||
@@ -65,33 +114,72 @@ export class LoginService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly loginCoreService: LoginCoreService,
|
private readonly loginCoreService: LoginCoreService,
|
||||||
|
private readonly zulipAccountService: ZulipAccountService,
|
||||||
|
@Inject('ZulipAccountsRepository')
|
||||||
|
private readonly zulipAccountsRepository: ZulipAccountsRepository,
|
||||||
|
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject('UsersService')
|
||||||
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户登录
|
* 用户登录
|
||||||
*
|
*
|
||||||
* @param loginRequest 登录请求
|
* 功能描述:
|
||||||
* @returns 登录响应
|
* 处理用户登录请求,验证用户凭据并生成JWT令牌
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 调用核心服务进行用户认证
|
||||||
|
* 2. 生成JWT访问令牌和刷新令牌
|
||||||
|
* 3. 记录登录日志和安全审计
|
||||||
|
* 4. 返回用户信息和令牌
|
||||||
|
*
|
||||||
|
* @param loginRequest 登录请求数据
|
||||||
|
* @returns Promise<ApiResponse<LoginResponse>> 登录响应
|
||||||
|
*
|
||||||
|
* @throws BadRequestException 当登录参数无效时
|
||||||
|
* @throws UnauthorizedException 当用户凭据错误时
|
||||||
|
* @throws InternalServerErrorException 当系统错误时
|
||||||
*/
|
*/
|
||||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`用户登录尝试: ${loginRequest.identifier}`);
|
this.logger.log('用户登录尝试', {
|
||||||
|
operation: 'login',
|
||||||
|
identifier: loginRequest.identifier,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
// 调用核心服务进行认证
|
// 1. 调用核心服务进行认证
|
||||||
const authResult = await this.loginCoreService.login(loginRequest);
|
const authResult = await this.loginCoreService.login(loginRequest);
|
||||||
|
|
||||||
// 生成访问令牌(实际应用中应使用JWT)
|
// 2. 生成JWT令牌对
|
||||||
const accessToken = this.generateAccessToken(authResult.user);
|
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||||
|
|
||||||
// 格式化响应数据
|
// 3. 格式化响应数据
|
||||||
const response: LoginResponse = {
|
const response: LoginResponse = {
|
||||||
user: this.formatUserInfo(authResult.user),
|
user: this.formatUserInfo(authResult.user),
|
||||||
access_token: accessToken,
|
access_token: tokenPair.access_token,
|
||||||
|
refresh_token: tokenPair.refresh_token,
|
||||||
|
expires_in: tokenPair.expires_in,
|
||||||
|
token_type: tokenPair.token_type,
|
||||||
is_new_user: authResult.isNewUser,
|
is_new_user: authResult.isNewUser,
|
||||||
message: '登录成功'
|
message: '登录成功'
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('用户登录成功', {
|
||||||
|
operation: 'login',
|
||||||
|
userId: authResult.user.id.toString(),
|
||||||
|
username: authResult.user.username,
|
||||||
|
isNewUser: authResult.isNewUser,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -99,11 +187,20 @@ export class LoginService {
|
|||||||
message: '登录成功'
|
message: '登录成功'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
const duration = Date.now() - startTime;
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('用户登录失败', {
|
||||||
|
operation: 'login',
|
||||||
|
identifier: loginRequest.identifier,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : '登录失败',
|
message: err.message || '登录失败',
|
||||||
error_code: 'LOGIN_FAILED'
|
error_code: 'LOGIN_FAILED'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -116,36 +213,109 @@ export class LoginService {
|
|||||||
* @returns 注册响应
|
* @returns 注册响应
|
||||||
*/
|
*/
|
||||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
||||||
|
|
||||||
// 调用核心服务进行注册
|
// 1. 初始化Zulip管理员客户端
|
||||||
|
await this.initializeZulipAdminClient();
|
||||||
|
|
||||||
|
// 2. 调用核心服务进行注册
|
||||||
const authResult = await this.loginCoreService.register(registerRequest);
|
const authResult = await this.loginCoreService.register(registerRequest);
|
||||||
|
|
||||||
// 生成访问令牌
|
// 3. 创建Zulip账号(使用相同的邮箱和密码)
|
||||||
const accessToken = this.generateAccessToken(authResult.user);
|
let zulipAccountCreated = false;
|
||||||
|
try {
|
||||||
|
if (registerRequest.email && registerRequest.password) {
|
||||||
|
await this.createZulipAccountForUser(authResult.user, registerRequest.password);
|
||||||
|
zulipAccountCreated = true;
|
||||||
|
|
||||||
|
this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, {
|
||||||
|
operation: 'register',
|
||||||
|
gameUserId: authResult.user.id.toString(),
|
||||||
|
email: registerRequest.email,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`跳过Zulip账号创建:缺少邮箱或密码`, {
|
||||||
|
operation: 'register',
|
||||||
|
username: registerRequest.username,
|
||||||
|
hasEmail: !!registerRequest.email,
|
||||||
|
hasPassword: !!registerRequest.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (zulipError) {
|
||||||
|
const err = zulipError as Error;
|
||||||
|
this.logger.error(`Zulip账号创建失败,回滚用户注册`, {
|
||||||
|
operation: 'register',
|
||||||
|
username: registerRequest.username,
|
||||||
|
gameUserId: authResult.user.id.toString(),
|
||||||
|
zulipError: err.message,
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
// 格式化响应数据
|
// 回滚游戏用户注册
|
||||||
|
try {
|
||||||
|
await this.loginCoreService.deleteUser(authResult.user.id);
|
||||||
|
this.logger.log(`用户注册回滚成功: ${registerRequest.username}`);
|
||||||
|
} catch (rollbackError) {
|
||||||
|
const rollbackErr = rollbackError as Error;
|
||||||
|
this.logger.error(`用户注册回滚失败`, {
|
||||||
|
operation: 'register',
|
||||||
|
username: registerRequest.username,
|
||||||
|
gameUserId: authResult.user.id.toString(),
|
||||||
|
rollbackError: rollbackErr.message,
|
||||||
|
}, rollbackErr.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抛出原始错误
|
||||||
|
throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 生成JWT令牌对
|
||||||
|
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||||
|
|
||||||
|
// 5. 格式化响应数据
|
||||||
const response: LoginResponse = {
|
const response: LoginResponse = {
|
||||||
user: this.formatUserInfo(authResult.user),
|
user: this.formatUserInfo(authResult.user),
|
||||||
access_token: accessToken,
|
access_token: tokenPair.access_token,
|
||||||
|
refresh_token: tokenPair.refresh_token,
|
||||||
|
expires_in: tokenPair.expires_in,
|
||||||
|
token_type: tokenPair.token_type,
|
||||||
is_new_user: true,
|
is_new_user: true,
|
||||||
message: '注册成功'
|
message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功'
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, {
|
||||||
|
operation: 'register',
|
||||||
|
gameUserId: authResult.user.id.toString(),
|
||||||
|
username: authResult.user.username,
|
||||||
|
zulipAccountCreated,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: response,
|
data: response,
|
||||||
message: '注册成功'
|
message: response.message
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error));
|
const duration = Date.now() - startTime;
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error(`用户注册失败: ${registerRequest.username}`, {
|
||||||
|
operation: 'register',
|
||||||
|
username: registerRequest.username,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : '注册失败',
|
message: err.message || '注册失败',
|
||||||
error_code: 'REGISTER_FAILED'
|
error_code: 'REGISTER_FAILED'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -164,13 +334,16 @@ export class LoginService {
|
|||||||
// 调用核心服务进行OAuth认证
|
// 调用核心服务进行OAuth认证
|
||||||
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
|
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
|
||||||
|
|
||||||
// 生成访问令牌
|
// 生成JWT令牌对
|
||||||
const accessToken = this.generateAccessToken(authResult.user);
|
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||||
|
|
||||||
// 格式化响应数据
|
// 格式化响应数据
|
||||||
const response: LoginResponse = {
|
const response: LoginResponse = {
|
||||||
user: this.formatUserInfo(authResult.user),
|
user: this.formatUserInfo(authResult.user),
|
||||||
access_token: accessToken,
|
access_token: tokenPair.access_token,
|
||||||
|
refresh_token: tokenPair.refresh_token,
|
||||||
|
expires_in: tokenPair.expires_in,
|
||||||
|
token_type: tokenPair.token_type,
|
||||||
is_new_user: authResult.isNewUser,
|
is_new_user: authResult.isNewUser,
|
||||||
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
|
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
|
||||||
};
|
};
|
||||||
@@ -457,23 +630,272 @@ export class LoginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成访问令牌
|
* 生成JWT令牌对
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 为用户生成访问令牌和刷新令牌,符合JWT标准和安全最佳实践
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 创建访问令牌载荷(短期有效)
|
||||||
|
* 2. 创建刷新令牌载荷(长期有效)
|
||||||
|
* 3. 使用配置的密钥签名令牌
|
||||||
|
* 4. 返回完整的令牌对信息
|
||||||
*
|
*
|
||||||
* @param user 用户信息
|
* @param user 用户信息
|
||||||
* @returns 访问令牌
|
* @returns Promise<TokenPair> JWT令牌对
|
||||||
|
*
|
||||||
|
* @throws InternalServerErrorException 当令牌生成失败时
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const tokenPair = await this.generateTokenPair(user);
|
||||||
|
* console.log(tokenPair.access_token); // JWT访问令牌
|
||||||
|
* console.log(tokenPair.refresh_token); // JWT刷新令牌
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
private generateAccessToken(user: Users): string {
|
private async generateTokenPair(user: Users): Promise<TokenPair> {
|
||||||
// 实际应用中应使用JWT库生成真正的JWT令牌
|
try {
|
||||||
// 这里仅用于演示,生成一个简单的令牌
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
const payload = {
|
const jwtSecret = this.configService.get<string>('JWT_SECRET');
|
||||||
userId: user.id.toString(),
|
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
|
||||||
username: user.username,
|
|
||||||
role: user.role,
|
if (!jwtSecret) {
|
||||||
timestamp: Date.now()
|
throw new Error('JWT_SECRET未配置');
|
||||||
};
|
}
|
||||||
|
|
||||||
// 简单的Base64编码(实际应用中应使用JWT)
|
// 1. 创建访问令牌载荷(不包含iss和aud,这些通过options传递)
|
||||||
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
const accessPayload: Omit<JwtPayload, 'iss' | 'aud' | 'iat' | 'exp'> = {
|
||||||
|
sub: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
email: user.email,
|
||||||
|
type: 'access',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 创建刷新令牌载荷(有效期更长)
|
||||||
|
const refreshPayload: Omit<JwtPayload, 'iss' | 'aud' | 'iat' | 'exp'> = {
|
||||||
|
sub: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
type: 'refresh',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 生成访问令牌(使用NestJS JwtService,通过options传递iss和aud)
|
||||||
|
const accessToken = await this.jwtService.signAsync(accessPayload, {
|
||||||
|
issuer: 'whale-town',
|
||||||
|
audience: 'whale-town-users',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 生成刷新令牌(有效期30天)
|
||||||
|
const refreshToken = jwt.sign(refreshPayload, jwtSecret, {
|
||||||
|
expiresIn: '30d',
|
||||||
|
issuer: 'whale-town',
|
||||||
|
audience: 'whale-town-users',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 计算过期时间(秒)
|
||||||
|
const expiresInSeconds = this.parseExpirationTime(expiresIn);
|
||||||
|
|
||||||
|
this.logger.log('JWT令牌对生成成功', {
|
||||||
|
operation: 'generateTokenPair',
|
||||||
|
userId: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
expiresIn: expiresInSeconds,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_in: expiresInSeconds,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('JWT令牌对生成失败', {
|
||||||
|
operation: 'generateTokenPair',
|
||||||
|
userId: user.id.toString(),
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
throw new Error(`令牌生成失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证JWT令牌
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 验证JWT令牌的有效性,包括签名、过期时间和载荷格式
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 验证令牌签名和格式
|
||||||
|
* 2. 检查令牌是否过期
|
||||||
|
* 3. 验证载荷数据完整性
|
||||||
|
* 4. 返回解码后的载荷信息
|
||||||
|
*
|
||||||
|
* @param token JWT令牌字符串
|
||||||
|
* @param tokenType 令牌类型(access 或 refresh)
|
||||||
|
* @returns Promise<JwtPayload> 解码后的载荷
|
||||||
|
*
|
||||||
|
* @throws UnauthorizedException 当令牌无效时
|
||||||
|
* @throws Error 当验证过程出错时
|
||||||
|
*/
|
||||||
|
async verifyToken(token: string, tokenType: 'access' | 'refresh' = 'access'): Promise<JwtPayload> {
|
||||||
|
try {
|
||||||
|
const jwtSecret = this.configService.get<string>('JWT_SECRET');
|
||||||
|
|
||||||
|
if (!jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET未配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 验证令牌并解码载荷
|
||||||
|
const payload = jwt.verify(token, jwtSecret, {
|
||||||
|
issuer: 'whale-town',
|
||||||
|
audience: 'whale-town-users',
|
||||||
|
}) as JwtPayload;
|
||||||
|
|
||||||
|
// 2. 验证令牌类型
|
||||||
|
if (payload.type !== tokenType) {
|
||||||
|
throw new Error(`令牌类型不匹配,期望: ${tokenType},实际: ${payload.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 验证载荷完整性
|
||||||
|
if (!payload.sub || !payload.username || payload.role === undefined) {
|
||||||
|
throw new Error('令牌载荷数据不完整');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('JWT令牌验证成功', {
|
||||||
|
operation: 'verifyToken',
|
||||||
|
userId: payload.sub,
|
||||||
|
username: payload.username,
|
||||||
|
tokenType: payload.type,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.warn('JWT令牌验证失败', {
|
||||||
|
operation: 'verifyToken',
|
||||||
|
tokenType,
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(`令牌验证失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新访问令牌
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 验证刷新令牌的有效性
|
||||||
|
* 2. 从数据库获取最新用户信息
|
||||||
|
* 3. 生成新的访问令牌
|
||||||
|
* 4. 可选择性地轮换刷新令牌
|
||||||
|
*
|
||||||
|
* @param refreshToken 刷新令牌
|
||||||
|
* @returns Promise<ApiResponse<TokenPair>> 新的令牌对
|
||||||
|
*
|
||||||
|
* @throws UnauthorizedException 当刷新令牌无效时
|
||||||
|
* @throws NotFoundException 当用户不存在时
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log('开始刷新访问令牌', {
|
||||||
|
operation: 'refreshAccessToken',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 验证刷新令牌
|
||||||
|
const payload = await this.verifyToken(refreshToken, 'refresh');
|
||||||
|
|
||||||
|
// 2. 获取最新用户信息
|
||||||
|
const user = await this.usersService.findOne(BigInt(payload.sub));
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('用户不存在或已被禁用');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成新的令牌对
|
||||||
|
const newTokenPair = await this.generateTokenPair(user);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('访问令牌刷新成功', {
|
||||||
|
operation: 'refreshAccessToken',
|
||||||
|
userId: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: newTokenPair,
|
||||||
|
message: '令牌刷新成功'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('访问令牌刷新失败', {
|
||||||
|
operation: 'refreshAccessToken',
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: err.message || '令牌刷新失败',
|
||||||
|
error_code: 'TOKEN_REFRESH_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析过期时间字符串
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 将时间字符串(如 '7d', '24h', '60m')转换为秒数
|
||||||
|
*
|
||||||
|
* @param expiresIn 过期时间字符串
|
||||||
|
* @returns number 过期时间(秒)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private parseExpirationTime(expiresIn: string): number {
|
||||||
|
if (!expiresIn || typeof expiresIn !== 'string') {
|
||||||
|
return 7 * 24 * 60 * 60; // 默认7天
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeUnit = expiresIn.slice(-1);
|
||||||
|
const timeValue = parseInt(expiresIn.slice(0, -1));
|
||||||
|
|
||||||
|
if (isNaN(timeValue)) {
|
||||||
|
return 7 * 24 * 60 * 60; // 默认7天
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (timeUnit) {
|
||||||
|
case 's': return timeValue;
|
||||||
|
case 'm': return timeValue * 60;
|
||||||
|
case 'h': return timeValue * 60 * 60;
|
||||||
|
case 'd': return timeValue * 24 * 60 * 60;
|
||||||
|
case 'w': return timeValue * 7 * 24 * 60 * 60;
|
||||||
|
default: return 7 * 24 * 60 * 60; // 默认7天
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 验证码登录
|
* 验证码登录
|
||||||
@@ -488,13 +910,16 @@ export class LoginService {
|
|||||||
// 调用核心服务进行验证码认证
|
// 调用核心服务进行验证码认证
|
||||||
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
|
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
|
||||||
|
|
||||||
// 生成访问令牌
|
// 生成JWT令牌对
|
||||||
const accessToken = this.generateAccessToken(authResult.user);
|
const tokenPair = await this.generateTokenPair(authResult.user);
|
||||||
|
|
||||||
// 格式化响应数据
|
// 格式化响应数据
|
||||||
const response: LoginResponse = {
|
const response: LoginResponse = {
|
||||||
user: this.formatUserInfo(authResult.user),
|
user: this.formatUserInfo(authResult.user),
|
||||||
access_token: accessToken,
|
access_token: tokenPair.access_token,
|
||||||
|
refresh_token: tokenPair.refresh_token,
|
||||||
|
expires_in: tokenPair.expires_in,
|
||||||
|
token_type: tokenPair.token_type,
|
||||||
is_new_user: authResult.isNewUser,
|
is_new_user: authResult.isNewUser,
|
||||||
message: '验证码登录成功'
|
message: '验证码登录成功'
|
||||||
};
|
};
|
||||||
@@ -592,4 +1017,171 @@ export class LoginService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化Zulip管理员客户端
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用环境变量中的管理员凭证初始化Zulip客户端
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 从环境变量获取管理员配置
|
||||||
|
* 2. 验证配置完整性
|
||||||
|
* 3. 初始化ZulipAccountService的管理员客户端
|
||||||
|
*
|
||||||
|
* @throws Error 当配置缺失或初始化失败时
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async initializeZulipAdminClient(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 从环境变量获取管理员配置
|
||||||
|
const adminConfig = {
|
||||||
|
realm: process.env.ZULIP_SERVER_URL || '',
|
||||||
|
username: process.env.ZULIP_BOT_EMAIL || '',
|
||||||
|
apiKey: process.env.ZULIP_BOT_API_KEY || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证配置完整性
|
||||||
|
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
|
||||||
|
throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化管理员客户端
|
||||||
|
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
|
throw new Error('Zulip管理员客户端初始化失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Zulip管理员客户端初始化成功', {
|
||||||
|
operation: 'initializeZulipAdminClient',
|
||||||
|
realm: adminConfig.realm,
|
||||||
|
adminEmail: adminConfig.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('Zulip管理员客户端初始化失败', {
|
||||||
|
operation: 'initializeZulipAdminClient',
|
||||||
|
error: err.message,
|
||||||
|
}, err.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户创建Zulip账号
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 为新注册的游戏用户创建对应的Zulip账号并建立关联
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 使用相同的邮箱和密码创建Zulip账号
|
||||||
|
* 2. 加密存储API Key
|
||||||
|
* 3. 在数据库中建立关联关系
|
||||||
|
* 4. 处理创建失败的情况
|
||||||
|
*
|
||||||
|
* @param gameUser 游戏用户信息
|
||||||
|
* @param password 用户密码(明文)
|
||||||
|
* @throws Error 当Zulip账号创建失败时
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.log('开始为用户创建Zulip账号', {
|
||||||
|
operation: 'createZulipAccountForUser',
|
||||||
|
gameUserId: gameUser.id.toString(),
|
||||||
|
email: gameUser.email,
|
||||||
|
nickname: gameUser.nickname,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 检查是否已存在Zulip账号关联
|
||||||
|
const existingAccount = await this.zulipAccountsRepository.findByGameUserId(gameUser.id);
|
||||||
|
if (existingAccount) {
|
||||||
|
this.logger.warn('用户已存在Zulip账号关联,跳过创建', {
|
||||||
|
operation: 'createZulipAccountForUser',
|
||||||
|
gameUserId: gameUser.id.toString(),
|
||||||
|
existingZulipUserId: existingAccount.zulipUserId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建Zulip账号
|
||||||
|
const createResult = await this.zulipAccountService.createZulipAccount({
|
||||||
|
email: gameUser.email,
|
||||||
|
fullName: gameUser.nickname,
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResult.success) {
|
||||||
|
throw new Error(createResult.error || 'Zulip账号创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 存储API Key
|
||||||
|
if (createResult.apiKey) {
|
||||||
|
await this.apiKeySecurityService.storeApiKey(
|
||||||
|
gameUser.id.toString(),
|
||||||
|
createResult.apiKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 在数据库中创建关联记录
|
||||||
|
await this.zulipAccountsRepository.create({
|
||||||
|
gameUserId: gameUser.id,
|
||||||
|
zulipUserId: createResult.userId!,
|
||||||
|
zulipEmail: createResult.email!,
|
||||||
|
zulipFullName: gameUser.nickname,
|
||||||
|
zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话)
|
||||||
|
if (createResult.apiKey) {
|
||||||
|
await this.zulipAccountService.linkGameAccount(
|
||||||
|
gameUser.id.toString(),
|
||||||
|
createResult.userId!,
|
||||||
|
createResult.email!,
|
||||||
|
createResult.apiKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('Zulip账号创建和关联成功', {
|
||||||
|
operation: 'createZulipAccountForUser',
|
||||||
|
gameUserId: gameUser.id.toString(),
|
||||||
|
zulipUserId: createResult.userId,
|
||||||
|
zulipEmail: createResult.email,
|
||||||
|
hasApiKey: !!createResult.apiKey,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error('为用户创建Zulip账号失败', {
|
||||||
|
operation: 'createZulipAccountForUser',
|
||||||
|
gameUserId: gameUser.id.toString(),
|
||||||
|
email: gameUser.email,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
// 清理可能创建的部分数据
|
||||||
|
try {
|
||||||
|
await this.zulipAccountsRepository.deleteByGameUserId(gameUser.id);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
this.logger.warn('清理Zulip账号关联数据失败', {
|
||||||
|
operation: 'createZulipAccountForUser',
|
||||||
|
gameUserId: gameUser.id.toString(),
|
||||||
|
cleanupError: (cleanupError as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
560
src/business/auth/services/login.service.zulip-account.spec.ts
Normal file
560
src/business/auth/services/login.service.zulip-account.spec.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* LoginService Zulip账号创建属性测试
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试用户注册时Zulip账号创建的一致性
|
||||||
|
* - 验证账号关联和数据完整性
|
||||||
|
* - 测试失败回滚机制
|
||||||
|
*
|
||||||
|
* 属性测试:
|
||||||
|
* - 属性 13: Zulip账号创建一致性
|
||||||
|
* - 验证需求: 账号创建成功率和数据一致性
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fc from 'fast-check';
|
||||||
|
import { LoginService } from './login.service';
|
||||||
|
import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service';
|
||||||
|
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
|
||||||
|
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
|
||||||
|
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
|
||||||
|
import { Users } from '../../../core/db/users/users.entity';
|
||||||
|
import { ZulipAccounts } from '../../../core/db/zulip_accounts/zulip_accounts.entity';
|
||||||
|
|
||||||
|
describe('LoginService - Zulip账号创建属性测试', () => {
|
||||||
|
let loginService: LoginService;
|
||||||
|
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||||
|
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||||
|
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
|
||||||
|
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||||
|
|
||||||
|
// 测试用的模拟数据生成器
|
||||||
|
const validEmailArb = fc.string({ minLength: 5, maxLength: 50 })
|
||||||
|
.filter(s => s.includes('@') && s.includes('.'))
|
||||||
|
.map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`);
|
||||||
|
|
||||||
|
const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 })
|
||||||
|
.filter(s => /^[a-zA-Z0-9_]+$/.test(s));
|
||||||
|
|
||||||
|
const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 })
|
||||||
|
.filter(s => s.trim().length > 0);
|
||||||
|
|
||||||
|
const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 })
|
||||||
|
.filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s));
|
||||||
|
|
||||||
|
const registerRequestArb = fc.record({
|
||||||
|
username: validUsernameArb,
|
||||||
|
email: validEmailArb,
|
||||||
|
nickname: validNicknameArb,
|
||||||
|
password: validPasswordArb,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 创建模拟服务
|
||||||
|
const mockLoginCoreService = {
|
||||||
|
register: jest.fn(),
|
||||||
|
deleteUser: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockZulipAccountService = {
|
||||||
|
initializeAdminClient: jest.fn(),
|
||||||
|
createZulipAccount: jest.fn(),
|
||||||
|
linkGameAccount: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockZulipAccountsRepository = {
|
||||||
|
findByGameUserId: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
deleteByGameUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockApiKeySecurityService = {
|
||||||
|
storeApiKey: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
LoginService,
|
||||||
|
{
|
||||||
|
provide: LoginCoreService,
|
||||||
|
useValue: mockLoginCoreService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ZulipAccountService,
|
||||||
|
useValue: mockZulipAccountService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'ZulipAccountsRepository',
|
||||||
|
useValue: mockZulipAccountsRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ApiKeySecurityService,
|
||||||
|
useValue: mockApiKeySecurityService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: JwtService,
|
||||||
|
useValue: {
|
||||||
|
sign: jest.fn().mockReturnValue('mock_jwt_token'),
|
||||||
|
signAsync: jest.fn().mockResolvedValue('mock_jwt_token'),
|
||||||
|
verify: jest.fn(),
|
||||||
|
decode: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'JWT_SECRET':
|
||||||
|
return 'test_jwt_secret_key_for_testing';
|
||||||
|
case 'JWT_EXPIRES_IN':
|
||||||
|
return '7d';
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'UsersService',
|
||||||
|
useValue: {
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByUsername: jest.fn(),
|
||||||
|
findByEmail: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
loginService = module.get<LoginService>(LoginService);
|
||||||
|
loginCoreService = module.get(LoginCoreService);
|
||||||
|
zulipAccountService = module.get(ZulipAccountService);
|
||||||
|
zulipAccountsRepository = module.get('ZulipAccountsRepository');
|
||||||
|
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||||
|
|
||||||
|
// Mock LoginService 的 initializeZulipAdminClient 方法
|
||||||
|
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// 设置环境变量模拟
|
||||||
|
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
|
||||||
|
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
|
||||||
|
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// 清理环境变量
|
||||||
|
delete process.env.ZULIP_SERVER_URL;
|
||||||
|
delete process.env.ZULIP_BOT_EMAIL;
|
||||||
|
delete process.env.ZULIP_BOT_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性 13: Zulip账号创建一致性
|
||||||
|
*
|
||||||
|
* 验证需求: 账号创建成功率和数据一致性
|
||||||
|
*
|
||||||
|
* 测试内容:
|
||||||
|
* 1. 成功注册时,游戏账号和Zulip账号都应该被创建
|
||||||
|
* 2. 账号关联信息应该正确存储
|
||||||
|
* 3. Zulip账号创建失败时,游戏账号应该被回滚
|
||||||
|
* 4. 数据一致性:邮箱、昵称等信息应该保持一致
|
||||||
|
*/
|
||||||
|
describe('属性 13: Zulip账号创建一致性', () => {
|
||||||
|
it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||||
|
// 准备测试数据
|
||||||
|
const mockGameUser: Users = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
username: registerRequest.username,
|
||||||
|
email: registerRequest.email,
|
||||||
|
nickname: registerRequest.nickname,
|
||||||
|
password_hash: 'hashed_password',
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
const mockZulipResult = {
|
||||||
|
success: true,
|
||||||
|
userId: Math.floor(Math.random() * 1000000),
|
||||||
|
email: registerRequest.email,
|
||||||
|
apiKey: 'zulip_api_key_' + Math.random().toString(36),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockZulipAccount: ZulipAccounts = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
gameUserId: mockGameUser.id,
|
||||||
|
zulipUserId: mockZulipResult.userId,
|
||||||
|
zulipEmail: mockZulipResult.email,
|
||||||
|
zulipFullName: registerRequest.nickname,
|
||||||
|
zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as ZulipAccounts;
|
||||||
|
|
||||||
|
// 设置模拟行为
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockGameUser,
|
||||||
|
isNewUser: true,
|
||||||
|
});
|
||||||
|
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||||
|
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
|
||||||
|
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||||
|
zulipAccountsRepository.create.mockResolvedValue(mockZulipAccount);
|
||||||
|
zulipAccountService.linkGameAccount.mockResolvedValue(true);
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
const result = await loginService.register(registerRequest);
|
||||||
|
|
||||||
|
// 验证结果
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||||
|
expect(result.data?.user.email).toBe(registerRequest.email);
|
||||||
|
expect(result.data?.user.nickname).toBe(registerRequest.nickname);
|
||||||
|
expect(result.data?.is_new_user).toBe(true);
|
||||||
|
|
||||||
|
// 验证Zulip管理员客户端初始化
|
||||||
|
expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 验证游戏用户注册
|
||||||
|
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||||
|
|
||||||
|
// 验证Zulip账号创建
|
||||||
|
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||||
|
email: registerRequest.email,
|
||||||
|
fullName: registerRequest.nickname,
|
||||||
|
password: registerRequest.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证API Key存储
|
||||||
|
expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith(
|
||||||
|
mockGameUser.id.toString(),
|
||||||
|
mockZulipResult.apiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证账号关联创建
|
||||||
|
expect(zulipAccountsRepository.create).toHaveBeenCalledWith({
|
||||||
|
gameUserId: mockGameUser.id,
|
||||||
|
zulipUserId: mockZulipResult.userId,
|
||||||
|
zulipEmail: mockZulipResult.email,
|
||||||
|
zulipFullName: registerRequest.nickname,
|
||||||
|
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证内存关联
|
||||||
|
expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith(
|
||||||
|
mockGameUser.id.toString(),
|
||||||
|
mockZulipResult.userId,
|
||||||
|
mockZulipResult.email,
|
||||||
|
mockZulipResult.apiKey
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在Zulip账号创建失败时回滚游戏账号', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||||
|
// 准备测试数据
|
||||||
|
const mockGameUser: Users = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
username: registerRequest.username,
|
||||||
|
email: registerRequest.email,
|
||||||
|
nickname: registerRequest.nickname,
|
||||||
|
password_hash: 'hashed_password',
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
// 设置模拟行为 - Zulip账号创建失败
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockGameUser,
|
||||||
|
isNewUser: true,
|
||||||
|
});
|
||||||
|
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||||
|
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'Zulip服务器连接失败',
|
||||||
|
errorCode: 'CONNECTION_FAILED',
|
||||||
|
});
|
||||||
|
loginCoreService.deleteUser.mockResolvedValue(true);
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
const result = await loginService.register(registerRequest);
|
||||||
|
|
||||||
|
// 验证结果 - 注册应该失败
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Zulip账号创建失败');
|
||||||
|
|
||||||
|
// 验证游戏用户被创建
|
||||||
|
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||||
|
|
||||||
|
// 验证Zulip账号创建尝试
|
||||||
|
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||||
|
email: registerRequest.email,
|
||||||
|
fullName: registerRequest.nickname,
|
||||||
|
password: registerRequest.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证游戏用户被回滚删除
|
||||||
|
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id);
|
||||||
|
|
||||||
|
// 验证没有创建账号关联
|
||||||
|
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
|
||||||
|
expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled();
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理已存在Zulip账号关联的情况', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||||
|
// 准备测试数据
|
||||||
|
const mockGameUser: Users = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
username: registerRequest.username,
|
||||||
|
email: registerRequest.email,
|
||||||
|
nickname: registerRequest.nickname,
|
||||||
|
password_hash: 'hashed_password',
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
const existingZulipAccount: ZulipAccounts = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
gameUserId: mockGameUser.id,
|
||||||
|
zulipUserId: 12345,
|
||||||
|
zulipEmail: registerRequest.email,
|
||||||
|
zulipFullName: registerRequest.nickname,
|
||||||
|
zulipApiKeyEncrypted: 'existing_encrypted_key',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as ZulipAccounts;
|
||||||
|
|
||||||
|
// 设置模拟行为 - 已存在Zulip账号关联
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockGameUser,
|
||||||
|
isNewUser: true,
|
||||||
|
});
|
||||||
|
zulipAccountsRepository.findByGameUserId.mockResolvedValue(existingZulipAccount);
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
const result = await loginService.register(registerRequest);
|
||||||
|
|
||||||
|
// 验证结果 - 注册应该成功
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||||
|
|
||||||
|
// 验证游戏用户被创建
|
||||||
|
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||||
|
|
||||||
|
// 验证检查了现有关联
|
||||||
|
expect(zulipAccountsRepository.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id);
|
||||||
|
|
||||||
|
// 验证没有尝试创建新的Zulip账号
|
||||||
|
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||||
|
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理缺少邮箱或密码的注册请求', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.record({
|
||||||
|
username: validUsernameArb,
|
||||||
|
nickname: validNicknameArb,
|
||||||
|
email: fc.option(validEmailArb, { nil: undefined }),
|
||||||
|
password: fc.option(validPasswordArb, { nil: undefined }),
|
||||||
|
}),
|
||||||
|
async (registerRequest) => {
|
||||||
|
// 只测试缺少邮箱或密码的情况
|
||||||
|
if (registerRequest.email && registerRequest.password) {
|
||||||
|
return; // 跳过完整数据的情况
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备测试数据
|
||||||
|
const mockGameUser: Users = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
username: registerRequest.username,
|
||||||
|
email: registerRequest.email || null,
|
||||||
|
nickname: registerRequest.nickname,
|
||||||
|
password_hash: registerRequest.password ? 'hashed_password' : null,
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
// 设置模拟行为
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockGameUser,
|
||||||
|
isNewUser: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
const result = await loginService.register(registerRequest as RegisterRequest);
|
||||||
|
|
||||||
|
// 验证结果 - 注册应该成功,但跳过Zulip账号创建
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||||
|
expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息
|
||||||
|
|
||||||
|
// 验证游戏用户被创建
|
||||||
|
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||||
|
|
||||||
|
// 验证没有尝试创建Zulip账号
|
||||||
|
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||||
|
expect(zulipAccountsRepository.create).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理Zulip管理员客户端初始化失败', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||||
|
// 设置模拟行为 - 管理员客户端初始化失败
|
||||||
|
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||||
|
.mockRejectedValue(new Error('Zulip管理员客户端初始化失败'));
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
const result = await loginService.register(registerRequest);
|
||||||
|
|
||||||
|
// 验证结果 - 注册应该失败
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Zulip管理员客户端初始化失败');
|
||||||
|
|
||||||
|
// 验证没有尝试创建游戏用户
|
||||||
|
expect(loginCoreService.register).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 验证没有尝试创建Zulip账号
|
||||||
|
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 恢复 mock
|
||||||
|
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理环境变量缺失的情况', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||||
|
// 清除环境变量
|
||||||
|
delete process.env.ZULIP_SERVER_URL;
|
||||||
|
delete process.env.ZULIP_BOT_EMAIL;
|
||||||
|
delete process.env.ZULIP_BOT_API_KEY;
|
||||||
|
|
||||||
|
// 重新设置 mock 以模拟环境变量缺失的错误
|
||||||
|
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||||
|
.mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'));
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
const result = await loginService.register(registerRequest);
|
||||||
|
|
||||||
|
// 验证结果 - 注册应该失败
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('Zulip管理员配置不完整');
|
||||||
|
|
||||||
|
// 验证没有尝试创建游戏用户
|
||||||
|
expect(loginCoreService.register).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 恢复环境变量和 mock
|
||||||
|
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
|
||||||
|
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
|
||||||
|
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
|
||||||
|
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||||
|
}),
|
||||||
|
{ numRuns: 30 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据一致性验证测试
|
||||||
|
*
|
||||||
|
* 验证游戏账号和Zulip账号之间的数据一致性
|
||||||
|
*/
|
||||||
|
describe('数据一致性验证', () => {
|
||||||
|
it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||||
|
// 准备测试数据
|
||||||
|
const mockGameUser: Users = {
|
||||||
|
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||||
|
username: registerRequest.username,
|
||||||
|
email: registerRequest.email,
|
||||||
|
nickname: registerRequest.nickname,
|
||||||
|
password_hash: 'hashed_password',
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
} as Users;
|
||||||
|
|
||||||
|
const mockZulipResult = {
|
||||||
|
success: true,
|
||||||
|
userId: Math.floor(Math.random() * 1000000),
|
||||||
|
email: registerRequest.email,
|
||||||
|
apiKey: 'zulip_api_key_' + Math.random().toString(36),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置模拟行为
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockGameUser,
|
||||||
|
isNewUser: true,
|
||||||
|
});
|
||||||
|
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
|
||||||
|
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
|
||||||
|
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||||
|
zulipAccountsRepository.create.mockResolvedValue({} as ZulipAccounts);
|
||||||
|
zulipAccountService.linkGameAccount.mockResolvedValue(true);
|
||||||
|
|
||||||
|
// 执行注册
|
||||||
|
await loginService.register(registerRequest);
|
||||||
|
|
||||||
|
// 验证Zulip账号创建时使用了正确的数据
|
||||||
|
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||||
|
email: registerRequest.email, // 相同的邮箱
|
||||||
|
fullName: registerRequest.nickname, // 相同的昵称
|
||||||
|
password: registerRequest.password, // 相同的密码
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证账号关联存储了正确的数据
|
||||||
|
expect(zulipAccountsRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
gameUserId: mockGameUser.id,
|
||||||
|
zulipUserId: mockZulipResult.userId,
|
||||||
|
zulipEmail: registerRequest.email, // 相同的邮箱
|
||||||
|
zulipFullName: registerRequest.nickname, // 相同的昵称
|
||||||
|
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
* - 业务规则驱动的消息过滤和权限控制
|
* - 业务规则驱动的消息过滤和权限控制
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 2.0.0
|
* @version 1.1.0
|
||||||
* @since 2025-12-31
|
* @since 2026-01-06
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@@ -53,6 +53,7 @@ import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
|
|||||||
import { RedisModule } from '../../core/redis/redis.module';
|
import { RedisModule } from '../../core/redis/redis.module';
|
||||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -64,6 +65,8 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
|||||||
LoggerModule,
|
LoggerModule,
|
||||||
// 登录模块 - 提供用户认证和Token验证
|
// 登录模块 - 提供用户认证和Token验证
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
|
// 认证模块 - 提供JWT验证和用户认证服务
|
||||||
|
AuthModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// 主协调服务 - 整合各子服务,提供统一业务接口
|
// 主协调服务 - 整合各子服务,提供统一业务接口
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
ZulipClientInstance,
|
ZulipClientInstance,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
||||||
|
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
|
||||||
|
|
||||||
describe('ZulipService', () => {
|
describe('ZulipService', () => {
|
||||||
let service: ZulipService;
|
let service: ZulipService;
|
||||||
@@ -158,6 +159,19 @@ describe('ZulipService', () => {
|
|||||||
provide: 'ZULIP_CONFIG_SERVICE',
|
provide: 'ZULIP_CONFIG_SERVICE',
|
||||||
useValue: mockConfigManager,
|
useValue: mockConfigManager,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ApiKeySecurityService,
|
||||||
|
useValue: {
|
||||||
|
extractApiKey: jest.fn(),
|
||||||
|
validateApiKey: jest.fn(),
|
||||||
|
encryptApiKey: jest.fn(),
|
||||||
|
decryptApiKey: jest.fn(),
|
||||||
|
getApiKey: jest.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
* - 消息格式转换和过滤
|
* - 消息格式转换和过滤
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.0.0
|
* @version 1.1.0
|
||||||
* @since 2025-12-25
|
* @since 2026-01-06
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
IZulipClientPoolService,
|
IZulipClientPoolService,
|
||||||
IZulipConfigService,
|
IZulipConfigService,
|
||||||
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
} from '../../core/zulip/interfaces/zulip-core.interfaces';
|
||||||
|
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
|
||||||
|
import { LoginService } from '../auth/services/login.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 玩家登录请求接口
|
* 玩家登录请求接口
|
||||||
@@ -114,6 +116,8 @@ export class ZulipService {
|
|||||||
private readonly eventProcessor: ZulipEventProcessorService,
|
private readonly eventProcessor: ZulipEventProcessorService,
|
||||||
@Inject('ZULIP_CONFIG_SERVICE')
|
@Inject('ZULIP_CONFIG_SERVICE')
|
||||||
private readonly configManager: IZulipConfigService,
|
private readonly configManager: IZulipConfigService,
|
||||||
|
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||||
|
private readonly loginService: LoginService,
|
||||||
) {
|
) {
|
||||||
this.logger.log('ZulipService初始化完成');
|
this.logger.log('ZulipService初始化完成');
|
||||||
|
|
||||||
@@ -173,9 +177,7 @@ export class ZulipService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 验证游戏Token并获取用户信息
|
// 2. 验证游戏Token并获取用户信息 调用认证服务验证Token
|
||||||
// TODO: 实际项目中应该调用认证服务验证Token
|
|
||||||
// 这里暂时使用模拟数据
|
|
||||||
const userInfo = await this.validateGameToken(request.token);
|
const userInfo = await this.validateGameToken(request.token);
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
this.logger.warn('登录失败:Token验证失败', {
|
this.logger.warn('登录失败:Token验证失败', {
|
||||||
@@ -289,7 +291,7 @@ export class ZulipService {
|
|||||||
* 功能描述:
|
* 功能描述:
|
||||||
* 验证游戏Token的有效性,返回用户信息
|
* 验证游戏Token的有效性,返回用户信息
|
||||||
*
|
*
|
||||||
* @param token 游戏Token
|
* @param token 游戏Token (JWT)
|
||||||
* @returns Promise<UserInfo | null> 用户信息,验证失败返回null
|
* @returns Promise<UserInfo | null> 用户信息,验证失败返回null
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
@@ -300,68 +302,84 @@ export class ZulipService {
|
|||||||
zulipEmail?: string;
|
zulipEmail?: string;
|
||||||
zulipApiKey?: string;
|
zulipApiKey?: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
// TODO: 实际项目中应该调用认证服务验证Token (登录godot所获取的JWT token)
|
|
||||||
// 这里暂时使用模拟数据进行开发测试
|
|
||||||
|
|
||||||
this.logger.debug('验证游戏Token', {
|
this.logger.debug('验证游戏Token', {
|
||||||
operation: 'validateGameToken',
|
operation: 'validateGameToken',
|
||||||
tokenLength: token.length,
|
tokenLength: token.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 模拟Token验证
|
try {
|
||||||
// 实际实现应该:
|
// 1. 使用LoginService验证JWT token
|
||||||
// 1. 调用LoginService验证Token
|
const payload = await this.loginService.verifyToken(token, 'access');
|
||||||
// 2. 从数据库获取用户的Zulip API Key
|
|
||||||
// 3. 返回完整的用户信息
|
|
||||||
|
|
||||||
if (token.startsWith('invalid')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从Token中提取用户ID(模拟)
|
if (!payload || !payload.sub) {
|
||||||
const userId = `user_${token.substring(0, 8)}`;
|
this.logger.warn('Token载荷无效', {
|
||||||
|
operation: 'validateGameToken',
|
||||||
// 为测试用户提供真实的 Zulip API Key
|
});
|
||||||
let zulipApiKey = undefined;
|
return null;
|
||||||
let zulipEmail = undefined;
|
}
|
||||||
|
|
||||||
// 检查是否是配置了真实 Zulip API Key 的测试用户
|
const userId = payload.sub;
|
||||||
const hasTestApiKey = token.includes('lCPWCPf');
|
const username = payload.username || `user_${userId}`;
|
||||||
const hasUserApiKey = token.includes('W2KhXaQx');
|
const email = payload.email || `${userId}@example.com`;
|
||||||
const hasOldApiKey = token.includes('MZ1jEMQo');
|
|
||||||
const isRealUserToken = token === 'real_user_token_with_zulip_key_123';
|
this.logger.debug('Token解析成功', {
|
||||||
|
|
||||||
this.logger.log('Token检查', {
|
|
||||||
operation: 'validateGameToken',
|
|
||||||
userId,
|
|
||||||
tokenPrefix: token.substring(0, 20),
|
|
||||||
hasUserApiKey,
|
|
||||||
hasOldApiKey,
|
|
||||||
isRealUserToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isRealUserToken || hasUserApiKey || hasTestApiKey || hasOldApiKey) {
|
|
||||||
// 使用用户的真实 API Key
|
|
||||||
// 注意:这个API Key对应的Zulip用户邮箱是 user8@zulip.xinghangee.icu
|
|
||||||
zulipApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8';
|
|
||||||
zulipEmail = 'angjustinl@mail.angforever.top';
|
|
||||||
|
|
||||||
this.logger.log('配置真实Zulip API Key', {
|
|
||||||
operation: 'validateGameToken',
|
operation: 'validateGameToken',
|
||||||
userId,
|
userId,
|
||||||
zulipEmail,
|
username,
|
||||||
hasApiKey: true,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. 从ApiKeySecurityService获取真实的Zulip API Key
|
||||||
|
let zulipApiKey = undefined;
|
||||||
|
let zulipEmail = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试从Redis获取存储的API Key
|
||||||
|
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||||
|
|
||||||
|
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
||||||
|
zulipApiKey = apiKeyResult.apiKey;
|
||||||
|
// 使用游戏账号的邮箱
|
||||||
|
zulipEmail = email;
|
||||||
|
|
||||||
|
this.logger.log('从存储获取到Zulip API Key', {
|
||||||
|
operation: 'validateGameToken',
|
||||||
|
userId,
|
||||||
|
hasApiKey: true,
|
||||||
|
apiKeyLength: zulipApiKey.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.debug('用户没有存储的Zulip API Key', {
|
||||||
|
operation: 'validateGameToken',
|
||||||
|
userId,
|
||||||
|
reason: apiKeyResult.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.warn('获取Zulip API Key失败', {
|
||||||
|
operation: 'validateGameToken',
|
||||||
|
userId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
zulipEmail,
|
||||||
|
zulipApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.warn('Token验证失败', {
|
||||||
|
operation: 'validateGameToken',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
username: `Player_${userId.substring(5, 10)}`,
|
|
||||||
email: `${userId}@example.com`,
|
|
||||||
// 实际项目中从数据库获取
|
|
||||||
zulipEmail,
|
|
||||||
zulipApiKey,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
* @since 2025-12-17
|
* @since 2025-12-17
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne } from 'typeorm';
|
||||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||||
|
import { ZulipAccounts } from '../zulip_accounts/zulip_accounts.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户实体类
|
* 用户实体类
|
||||||
@@ -432,4 +433,25 @@ export class Users {
|
|||||||
comment: '更新时间'
|
comment: '更新时间'
|
||||||
})
|
})
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联的Zulip账号
|
||||||
|
*
|
||||||
|
* 关系设计:
|
||||||
|
* - 类型:一对一关系(OneToOne)
|
||||||
|
* - 外键:在ZulipAccounts表中
|
||||||
|
* - 级联:不设置级联删除,保证数据安全
|
||||||
|
*
|
||||||
|
* 业务规则:
|
||||||
|
* - 每个游戏用户最多关联一个Zulip账号
|
||||||
|
* - 支持延迟加载,提高查询性能
|
||||||
|
* - 可选关联,不是所有用户都有Zulip账号
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 游戏内聊天功能集成
|
||||||
|
* - 跨平台消息同步
|
||||||
|
* - 用户身份验证和权限管理
|
||||||
|
*/
|
||||||
|
@OneToOne(() => ZulipAccounts, zulipAccount => zulipAccount.gameUser)
|
||||||
|
zulipAccount?: ZulipAccounts;
|
||||||
}
|
}
|
||||||
185
src/core/db/zulip_accounts/zulip_accounts.entity.ts
Normal file
185
src/core/db/zulip_accounts/zulip_accounts.entity.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Zulip账号关联实体
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 存储游戏用户与Zulip账号的关联关系
|
||||||
|
* - 管理Zulip账号的基本信息和状态
|
||||||
|
* - 提供账号验证和同步功能
|
||||||
|
*
|
||||||
|
* 关联关系:
|
||||||
|
* - 与Users表建立一对一关系
|
||||||
|
* - 存储Zulip用户ID、邮箱、API Key等信息
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToOne, JoinColumn, Index } from 'typeorm';
|
||||||
|
import { Users } from '../users/users.entity';
|
||||||
|
|
||||||
|
@Entity('zulip_accounts')
|
||||||
|
@Index(['zulip_user_id'], { unique: true })
|
||||||
|
@Index(['zulip_email'], { unique: true })
|
||||||
|
export class ZulipAccounts {
|
||||||
|
/**
|
||||||
|
* 主键ID
|
||||||
|
*/
|
||||||
|
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||||
|
id: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联的游戏用户ID
|
||||||
|
*/
|
||||||
|
@Column({ type: 'bigint', name: 'game_user_id', comment: '关联的游戏用户ID' })
|
||||||
|
gameUserId: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip用户ID
|
||||||
|
*/
|
||||||
|
@Column({ type: 'int', name: 'zulip_user_id', comment: 'Zulip服务器上的用户ID' })
|
||||||
|
zulipUserId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip用户邮箱
|
||||||
|
*/
|
||||||
|
@Column({ type: 'varchar', length: 255, name: 'zulip_email', comment: 'Zulip账号邮箱地址' })
|
||||||
|
zulipEmail: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip用户全名
|
||||||
|
*/
|
||||||
|
@Column({ type: 'varchar', length: 100, name: 'zulip_full_name', comment: 'Zulip账号全名' })
|
||||||
|
zulipFullName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip API Key(加密存储)
|
||||||
|
*/
|
||||||
|
@Column({ type: 'text', name: 'zulip_api_key_encrypted', comment: '加密存储的Zulip API Key' })
|
||||||
|
zulipApiKeyEncrypted: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号状态
|
||||||
|
* - active: 正常激活状态
|
||||||
|
* - inactive: 未激活状态
|
||||||
|
* - suspended: 暂停状态
|
||||||
|
* - error: 错误状态
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['active', 'inactive', 'suspended', 'error'],
|
||||||
|
default: 'active',
|
||||||
|
comment: '账号状态:active-正常,inactive-未激活,suspended-暂停,error-错误'
|
||||||
|
})
|
||||||
|
status: 'active' | 'inactive' | 'suspended' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最后验证时间
|
||||||
|
*/
|
||||||
|
@Column({ type: 'timestamp', name: 'last_verified_at', nullable: true, comment: '最后一次验证Zulip账号的时间' })
|
||||||
|
lastVerifiedAt: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最后同步时间
|
||||||
|
*/
|
||||||
|
@Column({ type: 'timestamp', name: 'last_synced_at', nullable: true, comment: '最后一次同步数据的时间' })
|
||||||
|
lastSyncedAt: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误信息
|
||||||
|
*/
|
||||||
|
@Column({ type: 'text', name: 'error_message', nullable: true, comment: '最后一次操作的错误信息' })
|
||||||
|
errorMessage: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试次数
|
||||||
|
*/
|
||||||
|
@Column({ type: 'int', name: 'retry_count', default: 0, comment: '创建或同步失败的重试次数' })
|
||||||
|
retryCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@CreateDateColumn({ name: 'created_at', comment: '记录创建时间' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', comment: '记录最后更新时间' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联的游戏用户
|
||||||
|
*/
|
||||||
|
@OneToOne(() => Users, user => user.zulipAccount)
|
||||||
|
@JoinColumn({ name: 'game_user_id' })
|
||||||
|
gameUser: Users;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账号是否处于正常状态
|
||||||
|
*
|
||||||
|
* @returns boolean 是否为正常状态
|
||||||
|
*/
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账号是否需要重新验证
|
||||||
|
*
|
||||||
|
* @param maxAge 最大验证间隔(毫秒),默认24小时
|
||||||
|
* @returns boolean 是否需要重新验证
|
||||||
|
*/
|
||||||
|
needsVerification(maxAge: number = 24 * 60 * 60 * 1000): boolean {
|
||||||
|
if (!this.lastVerifiedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeDiff = now.getTime() - this.lastVerifiedAt.getTime();
|
||||||
|
return timeDiff > maxAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新验证时间
|
||||||
|
*/
|
||||||
|
updateVerificationTime(): void {
|
||||||
|
this.lastVerifiedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新同步时间
|
||||||
|
*/
|
||||||
|
updateSyncTime(): void {
|
||||||
|
this.lastSyncedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置错误状态
|
||||||
|
*
|
||||||
|
* @param errorMessage 错误信息
|
||||||
|
*/
|
||||||
|
setError(errorMessage: string): void {
|
||||||
|
this.status = 'error';
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
this.retryCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除错误状态
|
||||||
|
*/
|
||||||
|
clearError(): void {
|
||||||
|
if (this.status === 'error') {
|
||||||
|
this.status = 'active';
|
||||||
|
this.errorMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置重试计数
|
||||||
|
*/
|
||||||
|
resetRetryCount(): void {
|
||||||
|
this.retryCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/core/db/zulip_accounts/zulip_accounts.module.ts
Normal file
81
src/core/db/zulip_accounts/zulip_accounts.module.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Zulip账号关联数据模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供Zulip账号关联数据的访问接口
|
||||||
|
* - 封装TypeORM实体和Repository
|
||||||
|
* - 为业务层提供数据访问服务
|
||||||
|
* - 支持数据库和内存模式的动态切换
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module, DynamicModule, Global } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||||
|
import { ZulipAccountsRepository } from './zulip_accounts.repository';
|
||||||
|
import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查数据库配置是否完整
|
||||||
|
*
|
||||||
|
* @returns 是否配置了数据库
|
||||||
|
*/
|
||||||
|
function isDatabaseConfigured(): boolean {
|
||||||
|
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
|
||||||
|
return requiredEnvVars.every(varName => process.env[varName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({})
|
||||||
|
export class ZulipAccountsModule {
|
||||||
|
/**
|
||||||
|
* 创建数据库模式的Zulip账号模块
|
||||||
|
*
|
||||||
|
* @returns 配置了TypeORM的动态模块
|
||||||
|
*/
|
||||||
|
static forDatabase(): DynamicModule {
|
||||||
|
return {
|
||||||
|
module: ZulipAccountsModule,
|
||||||
|
imports: [TypeOrmModule.forFeature([ZulipAccounts])],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: 'ZulipAccountsRepository',
|
||||||
|
useClass: ZulipAccountsRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: ['ZulipAccountsRepository', TypeOrmModule],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建内存模式的Zulip账号模块
|
||||||
|
*
|
||||||
|
* @returns 配置了内存存储的动态模块
|
||||||
|
*/
|
||||||
|
static forMemory(): DynamicModule {
|
||||||
|
return {
|
||||||
|
module: ZulipAccountsModule,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: 'ZulipAccountsRepository',
|
||||||
|
useClass: ZulipAccountsMemoryRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: ['ZulipAccountsRepository'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据环境自动选择模式
|
||||||
|
*
|
||||||
|
* @returns 动态模块
|
||||||
|
*/
|
||||||
|
static forRoot(): DynamicModule {
|
||||||
|
return isDatabaseConfigured()
|
||||||
|
? ZulipAccountsModule.forDatabase()
|
||||||
|
: ZulipAccountsModule.forMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/core/db/zulip_accounts/zulip_accounts.repository.ts
Normal file
323
src/core/db/zulip_accounts/zulip_accounts.repository.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Zulip账号关联数据访问层
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供Zulip账号关联数据的CRUD操作
|
||||||
|
* - 封装复杂查询逻辑和数据库交互
|
||||||
|
* - 实现数据访问层的业务逻辑抽象
|
||||||
|
*
|
||||||
|
* 主要功能:
|
||||||
|
* - 账号关联的创建、查询、更新、删除
|
||||||
|
* - 支持按游戏用户ID、Zulip用户ID、邮箱查询
|
||||||
|
* - 提供账号状态管理和批量操作
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||||
|
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Zulip账号关联的数据传输对象
|
||||||
|
*/
|
||||||
|
export interface CreateZulipAccountDto {
|
||||||
|
gameUserId: bigint;
|
||||||
|
zulipUserId: number;
|
||||||
|
zulipEmail: string;
|
||||||
|
zulipFullName: string;
|
||||||
|
zulipApiKeyEncrypted: string;
|
||||||
|
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新Zulip账号关联的数据传输对象
|
||||||
|
*/
|
||||||
|
export interface UpdateZulipAccountDto {
|
||||||
|
zulipFullName?: string;
|
||||||
|
zulipApiKeyEncrypted?: string;
|
||||||
|
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||||
|
lastVerifiedAt?: Date;
|
||||||
|
lastSyncedAt?: Date;
|
||||||
|
errorMessage?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip账号查询条件
|
||||||
|
*/
|
||||||
|
export interface ZulipAccountQueryOptions {
|
||||||
|
gameUserId?: bigint;
|
||||||
|
zulipUserId?: number;
|
||||||
|
zulipEmail?: string;
|
||||||
|
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||||
|
includeGameUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ZulipAccountsRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ZulipAccounts)
|
||||||
|
private readonly repository: Repository<ZulipAccounts>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param createDto 创建数据
|
||||||
|
* @returns Promise<ZulipAccounts> 创建的关联记录
|
||||||
|
*/
|
||||||
|
async create(createDto: CreateZulipAccountDto): Promise<ZulipAccounts> {
|
||||||
|
const zulipAccount = this.repository.create(createDto);
|
||||||
|
return await this.repository.save(zulipAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据游戏用户ID查找Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
const relations = includeGameUser ? ['gameUser'] : [];
|
||||||
|
|
||||||
|
return await this.repository.findOne({
|
||||||
|
where: { gameUserId },
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据Zulip用户ID查找账号关联
|
||||||
|
*
|
||||||
|
* @param zulipUserId Zulip用户ID
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
const relations = includeGameUser ? ['gameUser'] : [];
|
||||||
|
|
||||||
|
return await this.repository.findOne({
|
||||||
|
where: { zulipUserId },
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据Zulip邮箱查找账号关联
|
||||||
|
*
|
||||||
|
* @param zulipEmail Zulip邮箱
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
const relations = includeGameUser ? ['gameUser'] : [];
|
||||||
|
|
||||||
|
return await this.repository.findOne({
|
||||||
|
where: { zulipEmail },
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查找Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param id 关联记录ID
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findById(id: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
const relations = includeGameUser ? ['gameUser'] : [];
|
||||||
|
|
||||||
|
return await this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param id 关联记录ID
|
||||||
|
* @param updateDto 更新数据
|
||||||
|
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||||
|
*/
|
||||||
|
async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||||
|
await this.repository.update({ id }, updateDto);
|
||||||
|
return await this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据游戏用户ID更新Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @param updateDto 更新数据
|
||||||
|
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||||
|
*/
|
||||||
|
async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||||
|
await this.repository.update({ gameUserId }, updateDto);
|
||||||
|
return await this.findByGameUserId(gameUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param id 关联记录ID
|
||||||
|
* @returns Promise<boolean> 是否删除成功
|
||||||
|
*/
|
||||||
|
async delete(id: bigint): Promise<boolean> {
|
||||||
|
const result = await this.repository.delete({ id });
|
||||||
|
return result.affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据游戏用户ID删除Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @returns Promise<boolean> 是否删除成功
|
||||||
|
*/
|
||||||
|
async deleteByGameUserId(gameUserId: bigint): Promise<boolean> {
|
||||||
|
const result = await this.repository.delete({ gameUserId });
|
||||||
|
return result.affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询多个Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param options 查询选项
|
||||||
|
* @returns Promise<ZulipAccounts[]> 关联记录列表
|
||||||
|
*/
|
||||||
|
async findMany(options: ZulipAccountQueryOptions = {}): Promise<ZulipAccounts[]> {
|
||||||
|
const { includeGameUser, ...whereOptions } = options;
|
||||||
|
const relations = includeGameUser ? ['gameUser'] : [];
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const where: FindOptionsWhere<ZulipAccounts> = {};
|
||||||
|
if (whereOptions.gameUserId) where.gameUserId = whereOptions.gameUserId;
|
||||||
|
if (whereOptions.zulipUserId) where.zulipUserId = whereOptions.zulipUserId;
|
||||||
|
if (whereOptions.zulipEmail) where.zulipEmail = whereOptions.zulipEmail;
|
||||||
|
if (whereOptions.status) where.status = whereOptions.status;
|
||||||
|
|
||||||
|
return await this.repository.find({
|
||||||
|
where,
|
||||||
|
relations,
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取需要验证的账号列表
|
||||||
|
*
|
||||||
|
* @param maxAge 最大验证间隔(毫秒),默认24小时
|
||||||
|
* @returns Promise<ZulipAccounts[]> 需要验证的账号列表
|
||||||
|
*/
|
||||||
|
async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise<ZulipAccounts[]> {
|
||||||
|
const cutoffTime = new Date(Date.now() - maxAge);
|
||||||
|
|
||||||
|
return await this.repository
|
||||||
|
.createQueryBuilder('zulip_accounts')
|
||||||
|
.where('zulip_accounts.status = :status', { status: 'active' })
|
||||||
|
.andWhere(
|
||||||
|
'(zulip_accounts.last_verified_at IS NULL OR zulip_accounts.last_verified_at < :cutoffTime)',
|
||||||
|
{ cutoffTime }
|
||||||
|
)
|
||||||
|
.orderBy('zulip_accounts.last_verified_at', 'ASC', 'NULLS FIRST')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取错误状态的账号列表
|
||||||
|
*
|
||||||
|
* @param maxRetryCount 最大重试次数,默认3次
|
||||||
|
* @returns Promise<ZulipAccounts[]> 错误状态的账号列表
|
||||||
|
*/
|
||||||
|
async findErrorAccounts(maxRetryCount: number = 3): Promise<ZulipAccounts[]> {
|
||||||
|
return await this.repository.find({
|
||||||
|
where: { status: 'error' },
|
||||||
|
order: { updatedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新账号状态
|
||||||
|
*
|
||||||
|
* @param ids 账号ID列表
|
||||||
|
* @param status 新状态
|
||||||
|
* @returns Promise<number> 更新的记录数
|
||||||
|
*/
|
||||||
|
async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise<number> {
|
||||||
|
const result = await this.repository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(ZulipAccounts)
|
||||||
|
.set({ status })
|
||||||
|
.whereInIds(ids)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return result.affected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态的账号数量
|
||||||
|
*
|
||||||
|
* @returns Promise<Record<string, number>> 状态统计
|
||||||
|
*/
|
||||||
|
async getStatusStatistics(): Promise<Record<string, number>> {
|
||||||
|
const result = await this.repository
|
||||||
|
.createQueryBuilder('zulip_accounts')
|
||||||
|
.select('zulip_accounts.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.groupBy('zulip_accounts.status')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const statistics: Record<string, number> = {};
|
||||||
|
result.forEach(row => {
|
||||||
|
statistics[row.status] = parseInt(row.count, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查邮箱是否已存在
|
||||||
|
*
|
||||||
|
* @param zulipEmail Zulip邮箱
|
||||||
|
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||||
|
* @returns Promise<boolean> 是否已存在
|
||||||
|
*/
|
||||||
|
async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise<boolean> {
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('zulip_accounts')
|
||||||
|
.where('zulip_accounts.zulip_email = :zulipEmail', { zulipEmail });
|
||||||
|
|
||||||
|
if (excludeId) {
|
||||||
|
queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await queryBuilder.getCount();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查Zulip用户ID是否已存在
|
||||||
|
*
|
||||||
|
* @param zulipUserId Zulip用户ID
|
||||||
|
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||||
|
* @returns Promise<boolean> 是否已存在
|
||||||
|
*/
|
||||||
|
async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise<boolean> {
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('zulip_accounts')
|
||||||
|
.where('zulip_accounts.zulip_user_id = :zulipUserId', { zulipUserId });
|
||||||
|
|
||||||
|
if (excludeId) {
|
||||||
|
queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await queryBuilder.getCount();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
299
src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts
Normal file
299
src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Zulip账号关联内存数据访问层
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供Zulip账号关联数据的内存存储实现
|
||||||
|
* - 用于开发和测试环境
|
||||||
|
* - 实现与数据库版本相同的接口
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||||
|
import {
|
||||||
|
CreateZulipAccountDto,
|
||||||
|
UpdateZulipAccountDto,
|
||||||
|
ZulipAccountQueryOptions,
|
||||||
|
} from './zulip_accounts.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ZulipAccountsMemoryRepository {
|
||||||
|
private accounts: Map<bigint, ZulipAccounts> = new Map();
|
||||||
|
private currentId: bigint = BigInt(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param createDto 创建数据
|
||||||
|
* @returns Promise<ZulipAccounts> 创建的关联记录
|
||||||
|
*/
|
||||||
|
async create(createDto: CreateZulipAccountDto): Promise<ZulipAccounts> {
|
||||||
|
const account = new ZulipAccounts();
|
||||||
|
account.id = this.currentId++;
|
||||||
|
account.gameUserId = createDto.gameUserId;
|
||||||
|
account.zulipUserId = createDto.zulipUserId;
|
||||||
|
account.zulipEmail = createDto.zulipEmail;
|
||||||
|
account.zulipFullName = createDto.zulipFullName;
|
||||||
|
account.zulipApiKeyEncrypted = createDto.zulipApiKeyEncrypted;
|
||||||
|
account.status = createDto.status || 'active';
|
||||||
|
account.createdAt = new Date();
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
|
||||||
|
this.accounts.set(account.id, account);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据游戏用户ID查找Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
for (const account of this.accounts.values()) {
|
||||||
|
if (account.gameUserId === gameUserId) {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据Zulip用户ID查找账号关联
|
||||||
|
*
|
||||||
|
* @param zulipUserId Zulip用户ID
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
for (const account of this.accounts.values()) {
|
||||||
|
if (account.zulipUserId === zulipUserId) {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据Zulip邮箱查找账号关联
|
||||||
|
*
|
||||||
|
* @param zulipEmail Zulip邮箱
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
for (const account of this.accounts.values()) {
|
||||||
|
if (account.zulipEmail === zulipEmail) {
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查找Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param id 关联记录ID
|
||||||
|
* @param includeGameUser 是否包含游戏用户信息(内存模式忽略)
|
||||||
|
* @returns Promise<ZulipAccounts | null> 关联记录或null
|
||||||
|
*/
|
||||||
|
async findById(id: bigint, includeGameUser: boolean = false): Promise<ZulipAccounts | null> {
|
||||||
|
return this.accounts.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param id 关联记录ID
|
||||||
|
* @param updateDto 更新数据
|
||||||
|
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||||
|
*/
|
||||||
|
async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||||
|
const account = this.accounts.get(id);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(account, updateDto);
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据游戏用户ID更新Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @param updateDto 更新数据
|
||||||
|
* @returns Promise<ZulipAccounts | null> 更新后的记录或null
|
||||||
|
*/
|
||||||
|
async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise<ZulipAccounts | null> {
|
||||||
|
const account = await this.findByGameUserId(gameUserId);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(account, updateDto);
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param id 关联记录ID
|
||||||
|
* @returns Promise<boolean> 是否删除成功
|
||||||
|
*/
|
||||||
|
async delete(id: bigint): Promise<boolean> {
|
||||||
|
return this.accounts.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据游戏用户ID删除Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @returns Promise<boolean> 是否删除成功
|
||||||
|
*/
|
||||||
|
async deleteByGameUserId(gameUserId: bigint): Promise<boolean> {
|
||||||
|
for (const [id, account] of this.accounts.entries()) {
|
||||||
|
if (account.gameUserId === gameUserId) {
|
||||||
|
return this.accounts.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询多个Zulip账号关联
|
||||||
|
*
|
||||||
|
* @param options 查询选项
|
||||||
|
* @returns Promise<ZulipAccounts[]> 关联记录列表
|
||||||
|
*/
|
||||||
|
async findMany(options: ZulipAccountQueryOptions = {}): Promise<ZulipAccounts[]> {
|
||||||
|
let results = Array.from(this.accounts.values());
|
||||||
|
|
||||||
|
if (options.gameUserId) {
|
||||||
|
results = results.filter(a => a.gameUserId === options.gameUserId);
|
||||||
|
}
|
||||||
|
if (options.zulipUserId) {
|
||||||
|
results = results.filter(a => a.zulipUserId === options.zulipUserId);
|
||||||
|
}
|
||||||
|
if (options.zulipEmail) {
|
||||||
|
results = results.filter(a => a.zulipEmail === options.zulipEmail);
|
||||||
|
}
|
||||||
|
if (options.status) {
|
||||||
|
results = results.filter(a => a.status === options.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间降序排序
|
||||||
|
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取需要验证的账号列表
|
||||||
|
*
|
||||||
|
* @param maxAge 最大验证间隔(毫秒),默认24小时
|
||||||
|
* @returns Promise<ZulipAccounts[]> 需要验证的账号列表
|
||||||
|
*/
|
||||||
|
async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise<ZulipAccounts[]> {
|
||||||
|
const cutoffTime = new Date(Date.now() - maxAge);
|
||||||
|
|
||||||
|
return Array.from(this.accounts.values())
|
||||||
|
.filter(account =>
|
||||||
|
account.status === 'active' &&
|
||||||
|
(!account.lastVerifiedAt || account.lastVerifiedAt < cutoffTime)
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.lastVerifiedAt) return -1;
|
||||||
|
if (!b.lastVerifiedAt) return 1;
|
||||||
|
return a.lastVerifiedAt.getTime() - b.lastVerifiedAt.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取错误状态的账号列表
|
||||||
|
*
|
||||||
|
* @param maxRetryCount 最大重试次数(内存模式忽略)
|
||||||
|
* @returns Promise<ZulipAccounts[]> 错误状态的账号列表
|
||||||
|
*/
|
||||||
|
async findErrorAccounts(maxRetryCount: number = 3): Promise<ZulipAccounts[]> {
|
||||||
|
return Array.from(this.accounts.values())
|
||||||
|
.filter(account => account.status === 'error')
|
||||||
|
.sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新账号状态
|
||||||
|
*
|
||||||
|
* @param ids 账号ID列表
|
||||||
|
* @param status 新状态
|
||||||
|
* @returns Promise<number> 更新的记录数
|
||||||
|
*/
|
||||||
|
async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
const account = this.accounts.get(id);
|
||||||
|
if (account) {
|
||||||
|
account.status = status;
|
||||||
|
account.updatedAt = new Date();
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计各状态的账号数量
|
||||||
|
*
|
||||||
|
* @returns Promise<Record<string, number>> 状态统计
|
||||||
|
*/
|
||||||
|
async getStatusStatistics(): Promise<Record<string, number>> {
|
||||||
|
const statistics: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const account of this.accounts.values()) {
|
||||||
|
const status = account.status;
|
||||||
|
statistics[status] = (statistics[status] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查邮箱是否已存在
|
||||||
|
*
|
||||||
|
* @param zulipEmail Zulip邮箱
|
||||||
|
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||||
|
* @returns Promise<boolean> 是否已存在
|
||||||
|
*/
|
||||||
|
async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise<boolean> {
|
||||||
|
for (const [id, account] of this.accounts.entries()) {
|
||||||
|
if (account.zulipEmail === zulipEmail && (!excludeId || id !== excludeId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查Zulip用户ID是否已存在
|
||||||
|
*
|
||||||
|
* @param zulipUserId Zulip用户ID
|
||||||
|
* @param excludeId 排除的记录ID(用于更新时检查)
|
||||||
|
* @returns Promise<boolean> 是否已存在
|
||||||
|
*/
|
||||||
|
async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise<boolean> {
|
||||||
|
for (const [id, account] of this.accounts.entries()) {
|
||||||
|
if (account.zulipUserId === zulipUserId && (!excludeId || id !== excludeId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -826,4 +826,36 @@ export class LoginCoreService {
|
|||||||
VerificationCodeType.EMAIL_VERIFICATION
|
VerificationCodeType.EMAIL_VERIFICATION
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 删除指定的用户记录,用于注册失败时的回滚操作
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 验证用户是否存在
|
||||||
|
* 2. 执行用户删除操作
|
||||||
|
* 3. 返回删除结果
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @returns Promise<boolean> 是否删除成功
|
||||||
|
* @throws NotFoundException 用户不存在时
|
||||||
|
*/
|
||||||
|
async deleteUser(userId: bigint): Promise<boolean> {
|
||||||
|
// 1. 验证用户是否存在
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 执行删除操作
|
||||||
|
try {
|
||||||
|
await this.usersService.remove(userId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`删除用户失败: ${userId}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,9 @@ export const ThrottlePresets = {
|
|||||||
/** 密码重置:每小时3次 */
|
/** 密码重置:每小时3次 */
|
||||||
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' },
|
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' },
|
||||||
|
|
||||||
|
/** 令牌刷新:每分钟10次 */
|
||||||
|
REFRESH_TOKEN: { limit: 10, ttl: 60, message: '令牌刷新请求过于频繁,请稍后再试' },
|
||||||
|
|
||||||
/** 管理员操作:每分钟10次 */
|
/** 管理员操作:每分钟10次 */
|
||||||
ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' },
|
ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' },
|
||||||
|
|
||||||
|
|||||||
708
src/core/zulip/services/zulip_account.service.ts
Normal file
708
src/core/zulip/services/zulip_account.service.ts
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
/**
|
||||||
|
* Zulip账号管理核心服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 自动创建Zulip用户账号
|
||||||
|
* - 生成API Key并安全存储
|
||||||
|
* - 处理账号创建失败场景
|
||||||
|
* - 管理用户账号与游戏账号的关联
|
||||||
|
*
|
||||||
|
* 主要方法:
|
||||||
|
* - createZulipAccount(): 创建新的Zulip用户账号
|
||||||
|
* - generateApiKey(): 为用户生成API Key
|
||||||
|
* - validateZulipAccount(): 验证Zulip账号有效性
|
||||||
|
* - linkGameAccount(): 关联游戏账号与Zulip账号
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 用户注册时自动创建Zulip账号
|
||||||
|
* - API Key管理和更新
|
||||||
|
* - 账号关联和映射存储
|
||||||
|
*
|
||||||
|
* @author angjustinl
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-01-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ZulipClientConfig } from '../interfaces/zulip-core.interfaces';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip账号创建请求接口
|
||||||
|
*/
|
||||||
|
export interface CreateZulipAccountRequest {
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
password?: string;
|
||||||
|
shortName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip账号创建结果接口
|
||||||
|
*/
|
||||||
|
export interface CreateZulipAccountResult {
|
||||||
|
success: boolean;
|
||||||
|
userId?: number;
|
||||||
|
email?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
error?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key生成结果接口
|
||||||
|
*/
|
||||||
|
export interface GenerateApiKeyResult {
|
||||||
|
success: boolean;
|
||||||
|
apiKey?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号验证结果接口
|
||||||
|
*/
|
||||||
|
export interface ValidateAccountResult {
|
||||||
|
success: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
userInfo?: any;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号关联信息接口
|
||||||
|
*/
|
||||||
|
export interface AccountLinkInfo {
|
||||||
|
gameUserId: string;
|
||||||
|
zulipUserId: number;
|
||||||
|
zulipEmail: string;
|
||||||
|
zulipApiKey: string;
|
||||||
|
createdAt: Date;
|
||||||
|
lastVerified?: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zulip账号管理服务类
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 处理Zulip用户账号的创建和管理
|
||||||
|
* - 管理API Key的生成和存储
|
||||||
|
* - 维护游戏账号与Zulip账号的关联关系
|
||||||
|
* - 提供账号验证和状态检查功能
|
||||||
|
*
|
||||||
|
* 主要方法:
|
||||||
|
* - createZulipAccount(): 创建新的Zulip用户账号
|
||||||
|
* - generateApiKey(): 为现有用户生成API Key
|
||||||
|
* - validateZulipAccount(): 验证Zulip账号有效性
|
||||||
|
* - linkGameAccount(): 建立游戏账号与Zulip账号的关联
|
||||||
|
* - unlinkGameAccount(): 解除账号关联
|
||||||
|
*
|
||||||
|
* 使用场景:
|
||||||
|
* - 用户注册流程中自动创建Zulip账号
|
||||||
|
* - API Key管理和更新
|
||||||
|
* - 账号状态监控和维护
|
||||||
|
* - 跨平台账号同步
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ZulipAccountService {
|
||||||
|
private readonly logger = new Logger(ZulipAccountService.name);
|
||||||
|
private adminClient: any = null;
|
||||||
|
private readonly accountLinks = new Map<string, AccountLinkInfo>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger.log('ZulipAccountService初始化完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化管理员客户端
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用管理员凭证初始化Zulip客户端,用于创建用户账号
|
||||||
|
*
|
||||||
|
* @param adminConfig 管理员配置
|
||||||
|
* @returns Promise<boolean> 是否初始化成功
|
||||||
|
*/
|
||||||
|
async initializeAdminClient(adminConfig: ZulipClientConfig): Promise<boolean> {
|
||||||
|
this.logger.log('初始化Zulip管理员客户端', {
|
||||||
|
operation: 'initializeAdminClient',
|
||||||
|
realm: adminConfig.realm,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 动态导入zulip-js
|
||||||
|
const zulipInit = await this.loadZulipModule();
|
||||||
|
|
||||||
|
// 创建管理员客户端
|
||||||
|
this.adminClient = await zulipInit({
|
||||||
|
username: adminConfig.username,
|
||||||
|
apiKey: adminConfig.apiKey,
|
||||||
|
realm: adminConfig.realm,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证管理员权限
|
||||||
|
const profile = await this.adminClient.users.me.getProfile();
|
||||||
|
|
||||||
|
if (profile.result !== 'success') {
|
||||||
|
throw new Error(`管理员客户端验证失败: ${profile.msg || '未知错误'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('管理员客户端初始化成功', {
|
||||||
|
operation: 'initializeAdminClient',
|
||||||
|
adminEmail: profile.email,
|
||||||
|
isAdmin: profile.is_admin,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('管理员客户端初始化失败', {
|
||||||
|
operation: 'initializeAdminClient',
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Zulip用户账号
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用管理员权限在Zulip服务器上创建新的用户账号
|
||||||
|
*
|
||||||
|
* 业务逻辑:
|
||||||
|
* 1. 验证管理员客户端是否已初始化
|
||||||
|
* 2. 检查邮箱是否已存在
|
||||||
|
* 3. 生成用户密码(如果未提供)
|
||||||
|
* 4. 调用Zulip API创建用户
|
||||||
|
* 5. 为新用户生成API Key
|
||||||
|
* 6. 返回创建结果
|
||||||
|
*
|
||||||
|
* @param request 账号创建请求
|
||||||
|
* @returns Promise<CreateZulipAccountResult> 创建结果
|
||||||
|
*/
|
||||||
|
async createZulipAccount(request: CreateZulipAccountRequest): Promise<CreateZulipAccountResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.logger.log('开始创建Zulip账号', {
|
||||||
|
operation: 'createZulipAccount',
|
||||||
|
email: request.email,
|
||||||
|
fullName: request.fullName,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证管理员客户端
|
||||||
|
if (!this.adminClient) {
|
||||||
|
throw new Error('管理员客户端未初始化');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 验证请求参数
|
||||||
|
if (!request.email || !request.email.trim()) {
|
||||||
|
throw new Error('邮箱地址不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.fullName || !request.fullName.trim()) {
|
||||||
|
throw new Error('用户全名不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查邮箱格式
|
||||||
|
if (!this.isValidEmail(request.email)) {
|
||||||
|
throw new Error('邮箱格式无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检查用户是否已存在
|
||||||
|
const existingUser = await this.checkUserExists(request.email);
|
||||||
|
if (existingUser) {
|
||||||
|
this.logger.warn('用户已存在', {
|
||||||
|
operation: 'createZulipAccount',
|
||||||
|
email: request.email,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '用户已存在',
|
||||||
|
errorCode: 'USER_ALREADY_EXISTS',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 生成密码(如果未提供)
|
||||||
|
const password = request.password || this.generateRandomPassword();
|
||||||
|
const shortName = request.shortName || this.generateShortName(request.email);
|
||||||
|
|
||||||
|
// 6. 创建用户参数
|
||||||
|
const createParams = {
|
||||||
|
email: request.email,
|
||||||
|
password: password,
|
||||||
|
full_name: request.fullName,
|
||||||
|
short_name: shortName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7. 调用Zulip API创建用户
|
||||||
|
const createResponse = await this.adminClient.users.create(createParams);
|
||||||
|
|
||||||
|
if (createResponse.result !== 'success') {
|
||||||
|
this.logger.warn('Zulip用户创建失败', {
|
||||||
|
operation: 'createZulipAccount',
|
||||||
|
email: request.email,
|
||||||
|
error: createResponse.msg,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: createResponse.msg || '用户创建失败',
|
||||||
|
errorCode: 'ZULIP_CREATE_FAILED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 为新用户生成API Key
|
||||||
|
const apiKeyResult = await this.generateApiKeyForUser(request.email, password);
|
||||||
|
|
||||||
|
if (!apiKeyResult.success) {
|
||||||
|
this.logger.warn('API Key生成失败,但用户已创建', {
|
||||||
|
operation: 'createZulipAccount',
|
||||||
|
email: request.email,
|
||||||
|
error: apiKeyResult.error,
|
||||||
|
});
|
||||||
|
// 用户已创建,但API Key生成失败
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: createResponse.user_id,
|
||||||
|
email: request.email,
|
||||||
|
error: `用户创建成功,但API Key生成失败: ${apiKeyResult.error}`,
|
||||||
|
errorCode: 'API_KEY_GENERATION_FAILED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.log('Zulip账号创建成功', {
|
||||||
|
operation: 'createZulipAccount',
|
||||||
|
email: request.email,
|
||||||
|
userId: createResponse.user_id,
|
||||||
|
hasApiKey: !!apiKeyResult.apiKey,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: createResponse.user_id,
|
||||||
|
email: request.email,
|
||||||
|
apiKey: apiKeyResult.apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.logger.error('创建Zulip账号失败', {
|
||||||
|
operation: 'createZulipAccount',
|
||||||
|
email: request.email,
|
||||||
|
error: err.message,
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
errorCode: 'ACCOUNT_CREATION_FAILED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为用户生成API Key
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 使用用户凭证获取API Key
|
||||||
|
*
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @param password 用户密码
|
||||||
|
* @returns Promise<GenerateApiKeyResult> 生成结果
|
||||||
|
*/
|
||||||
|
async generateApiKeyForUser(email: string, password: string): Promise<GenerateApiKeyResult> {
|
||||||
|
this.logger.log('为用户生成API Key', {
|
||||||
|
operation: 'generateApiKeyForUser',
|
||||||
|
email,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 动态导入zulip-js
|
||||||
|
const zulipInit = await this.loadZulipModule();
|
||||||
|
|
||||||
|
// 使用用户凭证获取API Key
|
||||||
|
const userClient = await zulipInit({
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
realm: this.getRealmFromAdminClient(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证客户端并获取API Key
|
||||||
|
const profile = await userClient.users.me.getProfile();
|
||||||
|
|
||||||
|
if (profile.result !== 'success') {
|
||||||
|
throw new Error(`API Key获取失败: ${profile.msg || '未知错误'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从客户端配置中提取API Key
|
||||||
|
const apiKey = userClient.config?.apiKey;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('无法从客户端配置中获取API Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('API Key生成成功', {
|
||||||
|
operation: 'generateApiKeyForUser',
|
||||||
|
email,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
apiKey: apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('API Key生成失败', {
|
||||||
|
operation: 'generateApiKeyForUser',
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Zulip账号有效性
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 验证指定的Zulip账号是否存在且有效
|
||||||
|
*
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @param apiKey 用户API Key(可选)
|
||||||
|
* @returns Promise<ValidateAccountResult> 验证结果
|
||||||
|
*/
|
||||||
|
async validateZulipAccount(email: string, apiKey?: string): Promise<ValidateAccountResult> {
|
||||||
|
this.logger.log('验证Zulip账号', {
|
||||||
|
operation: 'validateZulipAccount',
|
||||||
|
email,
|
||||||
|
hasApiKey: !!apiKey,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (apiKey) {
|
||||||
|
// 使用API Key验证
|
||||||
|
const zulipInit = await this.loadZulipModule();
|
||||||
|
const userClient = await zulipInit({
|
||||||
|
username: email,
|
||||||
|
apiKey: apiKey,
|
||||||
|
realm: this.getRealmFromAdminClient(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = await userClient.users.me.getProfile();
|
||||||
|
|
||||||
|
if (profile.result === 'success') {
|
||||||
|
this.logger.log('账号验证成功(API Key)', {
|
||||||
|
operation: 'validateZulipAccount',
|
||||||
|
email,
|
||||||
|
userId: profile.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isValid: true,
|
||||||
|
userInfo: profile,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isValid: false,
|
||||||
|
error: profile.msg || 'API Key验证失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 仅检查用户是否存在
|
||||||
|
const userExists = await this.checkUserExists(email);
|
||||||
|
|
||||||
|
this.logger.log('账号存在性检查完成', {
|
||||||
|
operation: 'validateZulipAccount',
|
||||||
|
email,
|
||||||
|
exists: userExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isValid: userExists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('账号验证失败', {
|
||||||
|
operation: 'validateZulipAccount',
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联游戏账号与Zulip账号
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 建立游戏用户ID与Zulip账号的映射关系
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @param zulipUserId Zulip用户ID
|
||||||
|
* @param zulipEmail Zulip邮箱
|
||||||
|
* @param zulipApiKey Zulip API Key
|
||||||
|
* @returns Promise<boolean> 是否关联成功
|
||||||
|
*/
|
||||||
|
async linkGameAccount(
|
||||||
|
gameUserId: string,
|
||||||
|
zulipUserId: number,
|
||||||
|
zulipEmail: string,
|
||||||
|
zulipApiKey: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.logger.log('关联游戏账号与Zulip账号', {
|
||||||
|
operation: 'linkGameAccount',
|
||||||
|
gameUserId,
|
||||||
|
zulipUserId,
|
||||||
|
zulipEmail,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证参数
|
||||||
|
if (!gameUserId || !zulipUserId || !zulipEmail || !zulipApiKey) {
|
||||||
|
throw new Error('关联参数不完整');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建关联信息
|
||||||
|
const linkInfo: AccountLinkInfo = {
|
||||||
|
gameUserId,
|
||||||
|
zulipUserId,
|
||||||
|
zulipEmail,
|
||||||
|
zulipApiKey,
|
||||||
|
createdAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储关联信息(实际项目中应存储到数据库)
|
||||||
|
this.accountLinks.set(gameUserId, linkInfo);
|
||||||
|
|
||||||
|
this.logger.log('账号关联成功', {
|
||||||
|
operation: 'linkGameAccount',
|
||||||
|
gameUserId,
|
||||||
|
zulipUserId,
|
||||||
|
zulipEmail,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('账号关联失败', {
|
||||||
|
operation: 'linkGameAccount',
|
||||||
|
gameUserId,
|
||||||
|
zulipUserId,
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解除游戏账号与Zulip账号的关联
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @returns Promise<boolean> 是否解除成功
|
||||||
|
*/
|
||||||
|
async unlinkGameAccount(gameUserId: string): Promise<boolean> {
|
||||||
|
this.logger.log('解除账号关联', {
|
||||||
|
operation: 'unlinkGameAccount',
|
||||||
|
gameUserId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const linkInfo = this.accountLinks.get(gameUserId);
|
||||||
|
|
||||||
|
if (linkInfo) {
|
||||||
|
linkInfo.isActive = false;
|
||||||
|
this.accountLinks.delete(gameUserId);
|
||||||
|
|
||||||
|
this.logger.log('账号关联解除成功', {
|
||||||
|
operation: 'unlinkGameAccount',
|
||||||
|
gameUserId,
|
||||||
|
zulipEmail: linkInfo.zulipEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
this.logger.error('解除账号关联失败', {
|
||||||
|
operation: 'unlinkGameAccount',
|
||||||
|
gameUserId,
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取游戏账号的Zulip关联信息
|
||||||
|
*
|
||||||
|
* @param gameUserId 游戏用户ID
|
||||||
|
* @returns AccountLinkInfo | null 关联信息
|
||||||
|
*/
|
||||||
|
getAccountLink(gameUserId: string): AccountLinkInfo | null {
|
||||||
|
return this.accountLinks.get(gameUserId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有账号关联信息
|
||||||
|
*
|
||||||
|
* @returns AccountLinkInfo[] 所有关联信息
|
||||||
|
*/
|
||||||
|
getAllAccountLinks(): AccountLinkInfo[] {
|
||||||
|
return Array.from(this.accountLinks.values()).filter(link => link.isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已存在
|
||||||
|
*
|
||||||
|
* @param email 用户邮箱
|
||||||
|
* @returns Promise<boolean> 用户是否存在
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async checkUserExists(email: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!this.adminClient) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有用户列表
|
||||||
|
const usersResponse = await this.adminClient.users.retrieve();
|
||||||
|
|
||||||
|
if (usersResponse.result === 'success') {
|
||||||
|
const users = usersResponse.members || [];
|
||||||
|
return users.some((user: any) => user.email === email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.warn('检查用户存在性失败', {
|
||||||
|
operation: 'checkUserExists',
|
||||||
|
email,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱格式
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns boolean 是否为有效邮箱
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机密码
|
||||||
|
*
|
||||||
|
* @returns string 随机密码
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private generateRandomPassword(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从邮箱生成短名称
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns string 短名称
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private generateShortName(email: string): string {
|
||||||
|
const localPart = email.split('@')[0];
|
||||||
|
// 移除特殊字符,只保留字母数字和下划线
|
||||||
|
return localPart.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从管理员客户端获取Realm
|
||||||
|
*
|
||||||
|
* @returns string Realm URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getRealmFromAdminClient(): string {
|
||||||
|
if (!this.adminClient || !this.adminClient.config) {
|
||||||
|
throw new Error('管理员客户端未初始化或配置缺失');
|
||||||
|
}
|
||||||
|
return this.adminClient.config.realm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态加载zulip-js模块
|
||||||
|
*
|
||||||
|
* @returns Promise<any> zulip-js初始化函数
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async loadZulipModule(): Promise<any> {
|
||||||
|
try {
|
||||||
|
// 使用动态导入加载zulip-js
|
||||||
|
const zulipModule = await import('zulip-js');
|
||||||
|
return zulipModule.default || zulipModule;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('加载zulip-js模块失败', {
|
||||||
|
operation: 'loadZulipModule',
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}, err.stack);
|
||||||
|
throw new Error(`加载zulip-js模块失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { ApiKeySecurityService } from './services/api_key_security.service';
|
|||||||
import { ErrorHandlerService } from './services/error_handler.service';
|
import { ErrorHandlerService } from './services/error_handler.service';
|
||||||
import { MonitoringService } from './services/monitoring.service';
|
import { MonitoringService } from './services/monitoring.service';
|
||||||
import { StreamInitializerService } from './services/stream_initializer.service';
|
import { StreamInitializerService } from './services/stream_initializer.service';
|
||||||
|
import { ZulipAccountService } from './services/zulip_account.service';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -46,6 +47,7 @@ import { RedisModule } from '../redis/redis.module';
|
|||||||
ErrorHandlerService,
|
ErrorHandlerService,
|
||||||
MonitoringService,
|
MonitoringService,
|
||||||
StreamInitializerService,
|
StreamInitializerService,
|
||||||
|
ZulipAccountService,
|
||||||
|
|
||||||
// 直接提供类(用于内部依赖)
|
// 直接提供类(用于内部依赖)
|
||||||
ZulipClientService,
|
ZulipClientService,
|
||||||
@@ -63,6 +65,7 @@ import { RedisModule } from '../redis/redis.module';
|
|||||||
ErrorHandlerService,
|
ErrorHandlerService,
|
||||||
MonitoringService,
|
MonitoringService,
|
||||||
StreamInitializerService,
|
StreamInitializerService,
|
||||||
|
ZulipAccountService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ZulipCoreModule {}
|
export class ZulipCoreModule {}
|
||||||
Reference in New Issue
Block a user