17 Commits

Author SHA1 Message Date
1b380e4bb9 Merge branch 'main' into zulip_dev 2026-01-06 19:07:38 +08:00
angjustinl
8f9a6e7f9d feat(login, zulip): 引入 JWT 验证并重构 API 密钥管理
### 详细变更描述

* **修复 JWT 签名冲突**:重构 `LoginService.generateTokenPair()`,移除载荷(Payload)中的 `iss` (issuer) 与 `aud` (audience) 字段,解决签名校验失败的问题。
* **统一验证逻辑**:更新 `ZulipService` 以调用 `LoginService.verifyToken()`,消除重复的 JWT 校验代码,确保逻辑单一职责化(Single Responsibility)。
* **修复硬编码 API 密钥问题**:消息发送功能不再依赖静态配置,改为从 Redis 动态读取用户真实的 API 密钥。
* **解耦依赖注入**:在 `ZulipModule` 中注入 `AuthModule` 依赖,以支持标准的 Token 验证流程。
* **完善技术文档**:补充了关于 JWT 验证流程及 API 密钥管理逻辑的详细文档。
* **新增测试工具**:添加 `test-get-messages.js` 脚本,用于验证通过 WebSocket 接收消息的功能。
* **更新自动化脚本**:同步更新了 API 密钥验证及用户注册校验的快速测试脚本。
* **端到端功能验证**:确保消息发送逻辑能够正确映射并调用用户真实的 Zulip API 密钥。
2026-01-06 18:51:37 +08:00
07d9c736fa Merge pull request 'feat: 添加JWT认证系统和Zulip用户管理服务' (#32) from feature/jwt-auth-and-zulip-services into main
Reviewed-on: datawhale/whale-town-end#32
2026-01-06 16:54:59 +08:00
5e1afc2875 Merge branch 'main' into feature/jwt-auth-and-zulip-services 2026-01-06 16:54:52 +08:00
moyin
3733717d1f feat: 添加JWT令牌刷新功能
- 新增 @nestjs/jwt 和 jsonwebtoken 依赖包
- 实现 refreshAccessToken 方法支持令牌续期
- 添加 RefreshTokenDto 和 RefreshTokenResponseDto
- 新增 /auth/refresh-token 接口
- 完善令牌刷新的限流和超时控制
- 增加相关单元测试覆盖
- 优化错误处理和日志记录
2026-01-06 16:48:24 +08:00
moyin
470b0b8dbf feat: 添加JWT认证系统和Zulip用户管理服务
- 新增JWT认证守卫(JwtAuthGuard)和当前用户装饰器(CurrentUser)
- 添加JWT使用示例和完整的认证流程文档
- 实现Zulip用户管理服务,支持用户查询、验证和管理
- 实现Zulip用户注册服务,支持新用户创建和注册流程
- 添加完整的单元测试覆盖
- 新增真实环境测试脚本,验证Zulip API集成
- 更新.gitignore,排除.kiro目录

主要功能:
- JWT令牌验证和用户信息提取
- 用户存在性检查和信息获取
- Zulip API集成和错误处理
- 完整的测试覆盖和文档
2026-01-06 15:17:05 +08:00
c2ecb3c1a7 Merge pull request 'main' (#2) from main into zulip_dev
Reviewed-on: #2
2026-01-05 17:43:18 +08:00
fcb81f80d9 Merge pull request 'feat/websocket-remote-connection-fix' (#31) from feat/websocket-remote-connection-fix into main
Reviewed-on: datawhale/whale-town-end#31
2026-01-05 11:23:51 +08:00
065d3f2fc6 Merge branch 'main' into feat/websocket-remote-connection-fix 2026-01-05 11:23:42 +08:00
moyin
f335b72f6d chore:删除多余的文档 2026-01-05 11:23:07 +08:00
moyin
3bf1b6f474 config:添加nginx WebSocket代理配置文件
- nginx.conf: 当前生产环境的nginx配置
- nginx_complete_fix.conf: 完整的WebSocket支持配置模板

包含WebSocket升级映射、HTTP重定向、SSL配置等完整方案
支持ws://到wss://的协议升级和重定向处理
2026-01-05 11:17:16 +08:00
moyin
38f9f81b6c test:添加WebSocket连接诊断和测试工具集
- test_zulip.js: Zulip集成功能的端到端测试脚本
- full_diagnosis.js: 全面的WebSocket连接诊断工具
- test_protocol_difference.js: 不同协议(ws/wss/http/https)的对比测试
- test_redirect_and_websocket.js: HTTP重定向和WebSocket升级测试
- test_websocket_handshake_redirect.js: WebSocket握手重定向机制验证
- websocket_with_redirect_support.js: 支持重定向的WebSocket连接实现

提供完整的WebSocket连接问题诊断和解决方案
2026-01-05 11:16:52 +08:00
moyin
4818279fac chore:更新项目依赖和配置
- 更新WebSocket相关依赖版本
- 优化项目配置以支持远程连接
- 确保依赖兼容性和安全性
2026-01-05 11:15:30 +08:00
moyin
270e7e5bd2 test:大幅扩展Zulip核心服务的测试覆盖率
- API密钥安全服务:新增422个测试用例,覆盖加密、解密、验证等核心功能
- 配置管理服务:新增515个测试用例,覆盖配置加载、验证、更新等场景
- 错误处理服务:新增455个测试用例,覆盖各种错误场景和恢复机制
- 监控服务:新增360个测试用例,覆盖性能监控、健康检查等功能

总计新增1752个测试用例,显著提升代码质量和可靠性
2026-01-05 11:14:57 +08:00
moyin
e282c9dd16 service:完善Zulip服务的连接管理和错误处理
- 增强WebSocket连接状态监控
- 优化错误处理和重连机制
- 完善服务层的日志记录
- 提升连接稳定性和可靠性

支持远程WebSocket连接的服务层改进
2026-01-05 11:14:22 +08:00
moyin
d8b7143f60 websocket:增强Zulip WebSocket网关的调试和监控功能
- 添加详细的连接和断开日志记录
- 增强错误处理和异常捕获机制
- 完善客户端状态管理和会话跟踪
- 优化消息处理的调试输出

提升WebSocket连接问题的诊断能力
2026-01-05 11:14:04 +08:00
moyin
6002f53cbc config:优化WebSocket远程连接的CORS配置
- 明确指定允许的域名列表,包括生产环境域名
- 添加Vite开发服务器端口支持
- 完善CORS方法和头部配置,确保WebSocket握手正常
- 支持xinghangee.icu子域名的通配符匹配

修复远程域名WebSocket连接问题的核心配置
2026-01-05 11:13:43 +08:00
38 changed files with 6725 additions and 267 deletions

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ coverage/
# Redis数据文件本地开发用
redis-data/
.kiro/

View File

@@ -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 区域系统
- 添加 Stream Initializer Service 自动初始化服务
- 更新默认出生点为鲸之港 (Whale Port)

View File

@@ -68,4 +68,149 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot)
- Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。
- 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。
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
---

View 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();

View File

@@ -1,15 +1,102 @@
const zulip = require('zulip-js');
const axios = require('axios');
async function listSubscriptions() {
console.log('🔧 检查用户订阅的 Streams...');
const config = {
username: 'angjustinl@mail.angforever.top',
apiKey: 'lCPWC...pqNfGF8',
realm: 'https://zulip.xinghangee.icu/'
};
// 配置
const GAME_SERVER = 'http://localhost:3000';
const TEST_USER = {
username: 'angtest123',
password: 'angtest123',
email: 'angjustinl@163.com'
};
/**
* 登录游戏服务器获取用户信息
*/
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;
}
}
/**
* 使用密码获取 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);
// 获取用户信息
@@ -29,15 +116,15 @@ async function listSubscriptions() {
});
// 检查是否有 "Novice Village"
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village');
const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley');
if (noviceVillage) {
console.log('\n✅ "Novice Village" Stream 已存在!');
console.log('\n✅ "Pumpkin Valley" Stream 已存在!');
// 测试发送消息
console.log('\n📤 测试发送消息...');
const result = await client.messages.send({
type: 'stream',
to: 'Novice Village',
to: 'Pumpkin Valley',
subject: 'General',
content: '测试消息:系统集成测试成功 🎮'
});
@@ -48,7 +135,7 @@ async function listSubscriptions() {
console.log('❌ 消息发送失败:', result.msg);
}
} else {
console.log('\n⚠ "Novice Village" Stream 不存在');
console.log('\n⚠ "Pumpkin Valley" Stream 不存在');
console.log('💡 请在 Zulip 网页界面手动创建该 Stream或使用管理员账号创建');
// 尝试发送到第一个可用的 Stream
@@ -79,7 +166,9 @@ async function listSubscriptions() {
if (error.response) {
console.error('响应数据:', error.response.data);
}
process.exit(1);
}
}
// 运行测试
listSubscriptions();

View File

@@ -1,127 +1,183 @@
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 集成
async function testWithUserApiKey() {
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
console.log('📡 游戏服务器: http://localhost:3000/game');
console.log('🚀 开始测试用户 API Key Zulip 集成');
console.log('='.repeat(60));
const socket = io('http://localhost:3000/game', {
transports: ['websocket'],
timeout: 20000
});
let testStep = 0;
socket.on('connect', () => {
console.log('✅ WebSocket 连接成功');
testStep = 1;
try {
// 登录获取 token
const userInfo = await loginToGameServer();
// 使用包含用户 API Key 的 token
const loginMessage = {
type: 'login',
token: 'lCPWCPfGh7...fGF8_user_token'
};
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key');
socket.emit('login', loginMessage);
});
console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成');
console.log(` 连接到: ${GAME_SERVER}/game`);
socket.on('login_success', (data) => {
console.log('✅ 步骤 1 完成: 登录成功');
console.log(' 会话ID:', data.sessionId);
console.log(' 用户ID:', data.userId);
console.log(' 用户名:', data.username);
console.log(' 当前地图:', data.currentMap);
testStep = 2;
const socket = io(`${GAME_SERVER}/game`, {
transports: ['websocket'],
timeout: 20000
});
// 等待 Zulip 客户端初始化
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
setTimeout(() => {
const chatMessage = {
t: 'chat',
content: '🎮 【用户API Key测试】来自游戏的消息\\n' +
'时间: ' + new Date().toLocaleString() + '\\n' +
'使用用户 API Key 发送此消息。',
scope: 'local'
let testStep = 0;
socket.on('connect', () => {
console.log('✅ WebSocket 连接成功');
testStep = 1;
// 使用真实的 JWT token
const loginMessage = {
type: 'login',
token: userInfo.token
};
console.log('📤 步骤 2: 发送消息到 Zulip使用用户 API Key');
console.log(' 目标 Stream: Whale Port');
socket.emit('chat', chatMessage);
}, 3000);
});
console.log('📤 步骤 3: 发送登录消息(使用 JWT Token');
socket.emit('login', loginMessage);
});
socket.on('chat_sent', (data) => {
console.log('✅ 步骤 2 完成: 消息发送成功');
console.log(' 响应:', JSON.stringify(data, null, 2));
// 只在第一次收到 chat_sent 时发送第二条消息
if (testStep === 2) {
testStep = 3;
socket.on('login_success', (data) => {
console.log('✅ 步骤 3 完成: 登录成功');
console.log(' 会话ID:', data.sessionId);
console.log(' 用户ID:', data.userId);
console.log(' 用户名:', data.username);
console.log(' 当前地图:', data.currentMap);
testStep = 2;
// 等待 Zulip 客户端初始化
console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...');
setTimeout(() => {
// 先切换到 Pumpkin Valley 地图
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
const positionUpdate = {
t: 'position',
x: 150,
y: 400,
mapId: 'pumpkin_valley'
const chatMessage = {
t: 'chat',
content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` +
`时间: ${new Date().toLocaleString()}\n` +
`使用真实 API Key 发送此消息。`,
scope: 'local'
};
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(() => {
const chatMessage2 = {
t: 'chat',
content: '🎃 在南瓜谷发送的测试消息!',
scope: 'local'
// 先切换到 Pumpkin Valley 地图
console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图');
const positionUpdate = {
t: 'position',
x: 150,
y: 400,
mapId: 'pumpkin_valley'
};
socket.emit('position_update', positionUpdate);
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
socket.emit('chat', chatMessage2);
}, 1000);
}, 2000);
}
});
// 等待位置更新后发送消息
setTimeout(() => {
const chatMessage2 = {
t: 'chat',
content: '🎃 在南瓜谷发送的测试消息!',
scope: 'local'
};
console.log('📤 步骤 6: 在 Pumpkin Valley 发送消息');
socket.emit('chat', chatMessage2);
}, 1000);
}, 2000);
}
});
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 || '未知');
});
socket.on('error', (error) => {
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
});
socket.on('disconnect', () => {
console.log('🔌 WebSocket 连接已关闭');
console.log('');
console.log('📊 测试结果:');
console.log(' 完成步骤:', testStep, '/ 4');
if (testStep >= 3) {
console.log(' ✅ 核心功能正常!');
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
}
process.exit(0);
});
socket.on('connect_error', (error) => {
console.error('❌ 连接错误:', error.message);
socket.on('chat_render', (data) => {
console.log('\n📨 收到来自 Zulip 的消息:');
console.log(' 发送者:', data.from);
console.log(' 内容:', data.txt);
console.log(' Stream:', data.stream || '未知');
console.log(' Topic:', data.topic || '未知');
});
socket.on('error', (error) => {
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
});
socket.on('disconnect', () => {
console.log('\n🔌 WebSocket 连接已关闭');
console.log('\n' + '='.repeat(60));
console.log('📊 测试结果汇总');
console.log('='.repeat(60));
console.log(' 完成步骤:', testStep, '/ 3');
if (testStep >= 3) {
console.log(' ✅ 核心功能正常!');
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
} else {
console.log(' ⚠️ 部分测试未完成');
}
console.log('='.repeat(60));
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);
});
// 20秒后自动关闭给足够时间完成测试
setTimeout(() => {
console.log('⏰ 测试时间到,关闭连接');
socket.disconnect();
}, 20000);
}
}
console.log('🔧 准备测试环境...');
testWithUserApiKey().catch(console.error);
// 运行测试
testWithUserApiKey();

311
full_diagnosis.js Normal file
View File

@@ -0,0 +1,311 @@
const io = require('socket.io-client');
const https = require('https');
const http = require('http');
console.log('🔍 全面WebSocket连接诊断');
console.log('='.repeat(60));
// 1. 测试基础网络连接
async function testBasicConnection() {
console.log('\n1⃣ 测试基础HTTPS连接...');
return new Promise((resolve) => {
const options = {
hostname: 'whaletownend.xinghangee.icu',
port: 443,
path: '/',
method: 'GET',
timeout: 10000
};
const req = https.request(options, (res) => {
console.log(`✅ HTTPS连接成功 - 状态码: ${res.statusCode}`);
console.log(`📋 服务器: ${res.headers.server || '未知'}`);
resolve({ success: true, statusCode: res.statusCode });
});
req.on('error', (error) => {
console.log(`❌ HTTPS连接失败: ${error.message}`);
resolve({ success: false, error: error.message });
});
req.on('timeout', () => {
console.log('❌ HTTPS连接超时');
req.destroy();
resolve({ success: false, error: 'timeout' });
});
req.end();
});
}
// 2. 测试本地服务器
async function testLocalServer() {
console.log('\n2⃣ 测试本地服务器...');
const testPaths = [
'http://localhost:3000/',
'http://localhost:3000/socket.io/?EIO=4&transport=polling'
];
for (const url of testPaths) {
console.log(`🧪 测试: ${url}`);
await new Promise((resolve) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: 5000
};
const req = http.request(options, (res) => {
console.log(` 状态码: ${res.statusCode}`);
if (res.statusCode === 200) {
console.log(' ✅ 本地服务器正常');
} else {
console.log(` ⚠️ 本地服务器响应: ${res.statusCode}`);
}
resolve();
});
req.on('error', (error) => {
console.log(` ❌ 本地服务器连接失败: ${error.message}`);
resolve();
});
req.on('timeout', () => {
console.log(' ❌ 本地服务器超时');
req.destroy();
resolve();
});
req.end();
});
}
}
// 3. 测试远程Socket.IO路径
async function testRemoteSocketIO() {
console.log('\n3⃣ 测试远程Socket.IO路径...');
const testPaths = [
'/socket.io/?EIO=4&transport=polling',
'/game/socket.io/?EIO=4&transport=polling',
'/socket.io/?transport=polling',
'/api/socket.io/?EIO=4&transport=polling'
];
const results = [];
for (const path of testPaths) {
console.log(`🧪 测试路径: ${path}`);
const result = await new Promise((resolve) => {
const options = {
hostname: 'whaletownend.xinghangee.icu',
port: 443,
path: path,
method: 'GET',
timeout: 8000,
headers: {
'User-Agent': 'socket.io-diagnosis'
}
};
const req = https.request(options, (res) => {
console.log(` 状态码: ${res.statusCode}`);
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
console.log(' ✅ 路径可用');
console.log(` 📄 响应: ${data.substring(0, 50)}...`);
} else {
console.log(` ❌ 路径不可用: ${res.statusCode}`);
}
resolve({ path, statusCode: res.statusCode, success: res.statusCode === 200 });
});
});
req.on('error', (error) => {
console.log(` ❌ 请求失败: ${error.message}`);
resolve({ path, error: error.message, success: false });
});
req.on('timeout', () => {
console.log(' ❌ 请求超时');
req.destroy();
resolve({ path, error: 'timeout', success: false });
});
req.end();
});
results.push(result);
}
return results;
}
// 4. 测试Socket.IO客户端连接
async function testSocketIOClient() {
console.log('\n4⃣ 测试Socket.IO客户端连接...');
const configs = [
{
name: 'HTTPS + 所有传输方式',
url: 'https://whaletownend.xinghangee.icu',
options: { transports: ['websocket', 'polling'], timeout: 10000 }
},
{
name: 'HTTPS + 仅Polling',
url: 'https://whaletownend.xinghangee.icu',
options: { transports: ['polling'], timeout: 10000 }
},
{
name: 'HTTPS + /game namespace',
url: 'https://whaletownend.xinghangee.icu/game',
options: { transports: ['polling'], timeout: 10000 }
}
];
const results = [];
for (const config of configs) {
console.log(`🧪 测试: ${config.name}`);
console.log(` URL: ${config.url}`);
const result = await new Promise((resolve) => {
const socket = io(config.url, config.options);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
socket.disconnect();
console.log(' ❌ 连接超时');
resolve({ success: false, error: 'timeout' });
}
}, config.options.timeout);
socket.on('connect', () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
console.log(' ✅ 连接成功');
console.log(` 📡 Socket ID: ${socket.id}`);
console.log(` 🚀 传输方式: ${socket.io.engine.transport.name}`);
socket.disconnect();
resolve({ success: true, transport: socket.io.engine.transport.name });
}
});
socket.on('connect_error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
console.log(` ❌ 连接失败: ${error.message}`);
resolve({ success: false, error: error.message });
}
});
});
results.push({ config: config.name, ...result });
// 等待1秒再测试下一个
await new Promise(resolve => setTimeout(resolve, 1000));
}
return results;
}
// 5. 检查DNS解析
async function testDNS() {
console.log('\n5⃣ 检查DNS解析...');
const dns = require('dns');
return new Promise((resolve) => {
dns.lookup('whaletownend.xinghangee.icu', (err, address, family) => {
if (err) {
console.log(`❌ DNS解析失败: ${err.message}`);
resolve({ success: false, error: err.message });
} else {
console.log(`✅ DNS解析成功: ${address} (IPv${family})`);
resolve({ success: true, address, family });
}
});
});
}
// 主诊断函数
async function runFullDiagnosis() {
console.log('开始全面诊断...\n');
try {
const dnsResult = await testDNS();
const basicResult = await testBasicConnection();
await testLocalServer();
const socketIOPaths = await testRemoteSocketIO();
const clientResults = await testSocketIOClient();
console.log('\n' + '='.repeat(60));
console.log('📊 诊断结果汇总');
console.log('='.repeat(60));
console.log(`1. DNS解析: ${dnsResult.success ? '✅ 正常' : '❌ 失败'}`);
if (dnsResult.address) {
console.log(` IP地址: ${dnsResult.address}`);
}
console.log(`2. HTTPS连接: ${basicResult.success ? '✅ 正常' : '❌ 失败'}`);
if (basicResult.error) {
console.log(` 错误: ${basicResult.error}`);
}
const workingPaths = socketIOPaths.filter(r => r.success);
console.log(`3. Socket.IO路径: ${workingPaths.length}/${socketIOPaths.length} 个可用`);
workingPaths.forEach(p => {
console.log(`${p.path}`);
});
const workingClients = clientResults.filter(r => r.success);
console.log(`4. Socket.IO客户端: ${workingClients.length}/${clientResults.length} 个成功`);
workingClients.forEach(c => {
console.log(`${c.config} (${c.transport})`);
});
console.log('\n💡 建议:');
if (!dnsResult.success) {
console.log('❌ DNS解析失败 - 检查域名配置');
} else if (!basicResult.success) {
console.log('❌ 基础HTTPS连接失败 - 检查服务器状态和防火墙');
} else if (workingPaths.length === 0) {
console.log('❌ 所有Socket.IO路径都不可用 - 检查nginx配置和后端服务');
} else if (workingClients.length === 0) {
console.log('❌ Socket.IO客户端无法连接 - 可能是CORS或协议问题');
} else {
console.log('✅ 部分功能正常 - 使用可用的配置继续开发');
if (workingClients.length > 0) {
const bestConfig = workingClients.find(c => c.transport === 'websocket') || workingClients[0];
console.log(`💡 推荐使用: ${bestConfig.config}`);
}
}
} catch (error) {
console.error('诊断过程中发生错误:', error);
}
process.exit(0);
}
runFullDiagnosis();

View File

@@ -10,7 +10,10 @@
"start:prod": "node dist/main.js",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
"test:cov": "jest --coverage",
"test:e2e": "cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts",
"test:unit": "jest --testPathPattern=spec.ts --testPathIgnorePatterns=e2e.spec.ts",
"test:all": "cross-env RUN_E2E_TESTS=true jest"
},
"keywords": [
"game",
@@ -25,6 +28,7 @@
"@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^10.4.20",
"@nestjs/platform-socket.io": "^10.4.20",
"@nestjs/schedule": "^4.1.2",
@@ -40,6 +44,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^6.10.1",
@@ -59,9 +64,11 @@
"@nestjs/testing": "^10.4.20",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.27",
"@types/nodemailer": "^6.4.14",
"@types/supertest": "^6.0.3",
"cross-env": "^10.1.0",
"fast-check": "^4.5.2",
"jest": "^29.7.0",
"pino-pretty": "^13.1.3",

View File

@@ -6,6 +6,7 @@
* - 用户登录、注册、密码管理
* - GitHub OAuth集成
* - 邮箱验证功能
* - JWT令牌管理和验证
*
* @author kiro-ai
* @version 1.0.0
@@ -13,22 +14,41 @@
*/
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoginController } from './controllers/login.controller';
import { LoginService } from './services/login.service';
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({
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],
providers: [
LoginService,
],
exports: [LoginService],
exports: [LoginService, JwtModule],
})
export class AuthModule {}

View File

@@ -13,6 +13,7 @@
* - POST /auth/forgot-password - 发送密码重置验证码
* - POST /auth/reset-password - 重置密码
* - PUT /auth/change-password - 修改密码
* - POST /auth/refresh-token - 刷新访问令牌
*
* @author moyin angjustinl
* @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 { Response } from 'express';
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 {
LoginResponseDto,
RegisterResponseDto,
@@ -31,7 +32,8 @@ import {
ForgotPasswordResponseDto,
CommonResponseDto,
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto
SuccessEmailVerificationResponseDto,
RefreshTokenResponseDto
} from '../dto/login_response.dto';
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
@@ -609,4 +611,107 @@ export class LoginController {
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'
});
}
}
}

View File

@@ -0,0 +1,39 @@
/**
* 当前用户装饰器
*
* 功能描述:
* - 从请求上下文中提取当前认证用户信息
* - 简化控制器中获取用户信息的操作
*
* 使用示例:
* ```typescript
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* getProfile(@CurrentUser() user: JwtPayload) {
* return { user };
* }
* ```
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-05
*/
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedRequest, JwtPayload } from '../guards/jwt-auth.guard';
/**
* 当前用户装饰器
*
* @param data 可选的属性名,用于获取用户对象的特定属性
* @param ctx 执行上下文
* @returns 用户信息或用户的特定属性
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -424,4 +424,21 @@ export class SendLoginVerificationCodeDto {
@IsNotEmpty({ message: '登录标识符不能为空' })
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
identifier: string;
}
/**
* 刷新令牌请求DTO
*/
export class RefreshTokenDto {
/**
* 刷新令牌
*/
@ApiProperty({
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
minLength: 1
})
@IsString({ message: '刷新令牌必须是字符串' })
@IsNotEmpty({ message: '刷新令牌不能为空' })
refresh_token: string;
}

View File

@@ -80,17 +80,28 @@ export class LoginResponseDataDto {
user: UserInfoDto;
@ApiProperty({
description: '访问令牌',
description: 'JWT访问令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
access_token: string;
@ApiProperty({
description: '刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
required: false
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
refresh_token?: string;
refresh_token: string;
@ApiProperty({
description: '访问令牌过期时间(秒)',
example: 604800
})
expires_in: number;
@ApiProperty({
description: '令牌类型',
example: 'Bearer'
})
token_type: string;
@ApiProperty({
description: '是否为新用户',
@@ -392,4 +403,64 @@ export class SuccessEmailVerificationResponseDto {
required: false
})
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;
}

View File

@@ -0,0 +1,128 @@
/**
* JWT 使用示例
*
* 展示如何在控制器中使用 JWT 认证守卫和当前用户装饰器
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-05
*/
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
import { JwtAuthGuard, JwtPayload } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator';
/**
* 示例控制器 - 展示 JWT 认证的使用方法
*/
@Controller('example')
export class ExampleController {
/**
* 公开接口 - 无需认证
*/
@Get('public')
getPublicData() {
return {
message: '这是一个公开接口,无需认证',
timestamp: new Date().toISOString(),
};
}
/**
* 受保护的接口 - 需要 JWT 认证
*
* 请求头示例:
* Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
*/
@Get('protected')
@UseGuards(JwtAuthGuard)
getProtectedData(@CurrentUser() user: JwtPayload) {
return {
message: '这是一个受保护的接口,需要有效的 JWT 令牌',
user: {
id: user.sub,
username: user.username,
role: user.role,
},
timestamp: new Date().toISOString(),
};
}
/**
* 获取当前用户信息
*/
@Get('profile')
@UseGuards(JwtAuthGuard)
getUserProfile(@CurrentUser() user: JwtPayload) {
return {
profile: {
userId: user.sub,
username: user.username,
role: user.role,
tokenIssuedAt: new Date(user.iat * 1000).toISOString(),
tokenExpiresAt: new Date(user.exp * 1000).toISOString(),
},
};
}
/**
* 获取用户的特定属性
*/
@Get('username')
@UseGuards(JwtAuthGuard)
getUsername(@CurrentUser('username') username: string) {
return {
username,
message: `你好,${username}`,
};
}
/**
* 需要特定角色的接口
*/
@Post('admin-only')
@UseGuards(JwtAuthGuard)
adminOnlyAction(@CurrentUser() user: JwtPayload, @Body() data: any) {
// 检查用户角色
if (user.role !== 1) { // 假设 1 是管理员角色
return {
success: false,
message: '权限不足,仅管理员可访问',
};
}
return {
success: true,
message: '管理员操作执行成功',
data,
operator: user.username,
};
}
}
/**
* 使用说明:
*
* 1. 首先调用登录接口获取 JWT 令牌:
* POST /auth/login
* {
* "identifier": "username",
* "password": "password"
* }
*
* 2. 从响应中获取 access_token
*
* 3. 在后续请求中添加 Authorization 头:
* Authorization: Bearer <access_token>
*
* 4. 访问受保护的接口:
* GET /example/protected
* GET /example/profile
* GET /example/username
* POST /example/admin-only
*
* 错误处理:
* - 401 Unauthorized: 令牌缺失或无效
* - 403 Forbidden: 令牌有效但权限不足
*/

View File

@@ -0,0 +1,83 @@
/**
* JWT 认证守卫
*
* 功能描述:
* - 验证请求中的 JWT 令牌
* - 提取用户信息并添加到请求上下文
* - 保护需要认证的路由
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-05
*/
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
/**
* JWT 载荷接口
*/
export interface JwtPayload {
sub: string; // 用户ID
username: string;
role: number;
iat: number; // 签发时间
exp: number; // 过期时间
}
/**
* 扩展的请求接口,包含用户信息
*/
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
this.logger.warn('访问被拒绝:缺少认证令牌');
throw new UnauthorizedException('缺少认证令牌');
}
try {
// 验证并解码 JWT 令牌
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// 将用户信息添加到请求对象
(request as AuthenticatedRequest).user = payload;
this.logger.log(`用户认证成功: ${payload.username} (ID: ${payload.sub})`);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
this.logger.warn(`JWT 令牌验证失败: ${errorMessage}`);
throw new UnauthorizedException('无效的认证令牌');
}
}
/**
* 从请求头中提取 JWT 令牌
*
* @param request 请求对象
* @returns JWT 令牌或 undefined
*/
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -1,14 +1,43 @@
/**
* 登录业务服务测试
*
* 功能描述:
* - 测试登录相关的业务逻辑
* - 测试JWT令牌生成和验证
* - 测试令牌刷新功能
* - 测试各种异常情况处理
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-06
*/
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { LoginService } from './login.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', () => {
let service: LoginService;
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 = {
id: BigInt(1),
@@ -26,7 +55,20 @@ describe('LoginService', () => {
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 () => {
// 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 = {
login: jest.fn(),
register: jest.fn(),
@@ -40,6 +82,36 @@ describe('LoginService', () => {
verificationCodeLogin: jest.fn(),
sendLoginVerificationCode: 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({
@@ -49,11 +121,72 @@ describe('LoginService', () => {
provide: LoginCoreService,
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();
service = module.get<LoginService>(LoginService);
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', () => {
@@ -61,7 +194,7 @@ describe('LoginService', () => {
});
describe('login', () => {
it('should login successfully', async () => {
it('should login successfully and return JWT tokens', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
@@ -74,7 +207,40 @@ describe('LoginService', () => {
expect(result.success).toBe(true);
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 () => {
@@ -87,16 +253,80 @@ describe('LoginService', () => {
expect(result.success).toBe(false);
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', () => {
it('should register successfully', async () => {
it('should register successfully with JWT tokens', async () => {
loginCoreService.register.mockResolvedValue({
user: mockUser,
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({
username: 'testuser',
password: 'password123',
@@ -104,13 +334,323 @@ describe('LoginService', () => {
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.is_new_user).toBe(true);
expect(result.data?.message).toBe('注册成功');
// 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', () => {
it('should login with verification code successfully', async () => {
describe('verifyToken', () => {
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({
user: mockUser,
isNewUser: false
@@ -123,23 +663,74 @@ describe('LoginService', () => {
expect(result.success).toBe(true);
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 () => {
loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误'));
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '999999'
it('should handle sendPasswordResetCode in test mode', async () => {
loginCoreService.sendPasswordResetCode.mockResolvedValue({
code: '123456',
isTestMode: true
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED');
});
});
const result = await service.sendPasswordResetCode('test@example.com');
describe('sendLoginVerificationCode', () => {
it('should send login verification code successfully', async () => {
expect(result.success).toBe(false); // Test mode returns false
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({
code: '123456',
isTestMode: true
@@ -151,5 +742,22 @@ describe('LoginService', () => {
expect(result.data?.verification_code).toBe('123456');
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('调试信息获取成功');
});
});
});

View File

@@ -17,12 +17,54 @@
*/
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 { 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;
}
/**
* 登录响应数据接口
*/
@@ -38,10 +80,14 @@ export interface LoginResponse {
role: number;
created_at: Date;
};
/** 访问令牌实际应用中应生成JWT */
/** 访问令牌 */
access_token: string;
/** 刷新令牌 */
refresh_token?: string;
refresh_token: string;
/** 访问令牌过期时间(秒) */
expires_in: number;
/** 令牌类型 */
token_type: string;
/** 是否为新用户 */
is_new_user?: boolean;
/** 消息 */
@@ -72,33 +118,68 @@ export class LoginService {
@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>> {
const startTime = Date.now();
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);
// 生成访问令牌实际应用中应使用JWT
const accessToken = this.generateAccessToken(authResult.user);
// 2. 生成JWT令牌对
const tokenPair = await this.generateTokenPair(authResult.user);
// 格式化响应数据
// 3. 格式化响应数据
const response: LoginResponse = {
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,
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 {
success: true,
@@ -106,11 +187,20 @@ export class LoginService {
message: '登录成功'
};
} 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 {
success: false,
message: error instanceof Error ? error.message : '登录失败',
message: err.message || '登录失败',
error_code: 'LOGIN_FAILED'
};
}
@@ -181,13 +271,16 @@ export class LoginService {
throw new Error(`注册失败Zulip账号创建失败 - ${err.message}`);
}
// 4. 生成访问令牌
const accessToken = this.generateAccessToken(authResult.user);
// 4. 生成JWT令牌
const tokenPair = await this.generateTokenPair(authResult.user);
// 5. 格式化响应数据
const response: LoginResponse = {
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,
message: zulipAccountCreated ? '注册成功Zulip账号已同步创建' : '注册成功'
};
@@ -241,13 +334,16 @@ export class LoginService {
// 调用核心服务进行OAuth认证
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
// 生成访问令牌
const accessToken = this.generateAccessToken(authResult.user);
// 生成JWT令牌
const tokenPair = await this.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
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,
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
};
@@ -534,23 +630,272 @@ export class LoginService {
}
/**
* 生成访问令牌
* 生成JWT令牌
*
* 功能描述:
* 为用户生成访问令牌和刷新令牌符合JWT标准和安全最佳实践
*
* 业务逻辑:
* 1. 创建访问令牌载荷(短期有效)
* 2. 创建刷新令牌载荷(长期有效)
* 3. 使用配置的密钥签名令牌
* 4. 返回完整的令牌对信息
*
* @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 {
// 实际应用中应使用JWT库生成真正的JWT令牌
// 这里仅用于演示,生成一个简单的令牌
const payload = {
userId: user.id.toString(),
username: user.username,
role: user.role,
timestamp: Date.now()
};
private async generateTokenPair(user: Users): Promise<TokenPair> {
try {
const currentTime = Math.floor(Date.now() / 1000);
const jwtSecret = this.configService.get<string>('JWT_SECRET');
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
if (!jwtSecret) {
throw new Error('JWT_SECRET未配置');
}
// 简单的Base64编码实际应用中应使用JWT
return Buffer.from(JSON.stringify(payload)).toString('base64');
// 1. 创建访问令牌载荷不包含iss和aud这些通过options传递
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天
}
}
/**
* 验证码登录
@@ -565,13 +910,16 @@ export class LoginService {
// 调用核心服务进行验证码认证
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
// 生成访问令牌
const accessToken = this.generateAccessToken(authResult.user);
// 生成JWT令牌
const tokenPair = await this.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
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,
message: '验证码登录成功'
};

View File

@@ -18,6 +18,8 @@
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';
@@ -97,6 +99,41 @@ describe('LoginService - Zulip账号创建属性测试', () => {
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();
@@ -106,6 +143,9 @@ describe('LoginService - Zulip账号创建属性测试', () => {
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';
@@ -167,7 +207,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as ZulipAccounts;
// 设置模拟行为
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -189,11 +228,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
expect(result.data?.is_new_user).toBe(true);
// 验证Zulip管理员客户端初始化
expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({
realm: 'https://test.zulip.com',
username: 'bot@test.zulip.com',
apiKey: 'test_api_key_123',
});
expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled();
// 验证游戏用户注册
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
@@ -249,7 +284,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as Users;
// 设置模拟行为 - Zulip账号创建失败
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -318,7 +352,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as ZulipAccounts;
// 设置模拟行为 - 已存在Zulip账号关联
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -374,7 +407,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as Users;
// 设置模拟行为
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -404,7 +436,8 @@ describe('LoginService - Zulip账号创建属性测试', () => {
await fc.assert(
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
// 设置模拟行为 - 管理员客户端初始化失败
zulipAccountService.initializeAdminClient.mockResolvedValue(false);
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
.mockRejectedValue(new Error('Zulip管理员客户端初始化失败'));
// 执行注册
const result = await loginService.register(registerRequest);
@@ -418,6 +451,9 @@ describe('LoginService - Zulip账号创建属性测试', () => {
// 验证没有尝试创建Zulip账号
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
// 恢复 mock
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
}),
{ numRuns: 50 }
);
@@ -431,6 +467,10 @@ describe('LoginService - Zulip账号创建属性测试', () => {
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);
@@ -441,10 +481,11 @@ describe('LoginService - 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 }
);
@@ -480,7 +521,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
};
// 设置模拟行为
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,

View File

@@ -38,8 +38,8 @@
* - 业务规则驱动的消息过滤和权限控制
*
* @author angjustinl
* @version 2.0.0
* @since 2025-12-31
* @version 1.1.0
* @since 2026-01-06
*/
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 { LoggerModule } from '../../core/utils/logger/logger.module';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
@@ -64,6 +65,8 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module';
LoggerModule,
// 登录模块 - 提供用户认证和Token验证
LoginCoreModule,
// 认证模块 - 提供JWT验证和用户认证服务
AuthModule,
],
providers: [
// 主协调服务 - 整合各子服务,提供统一业务接口

View File

@@ -40,6 +40,7 @@ import {
ZulipClientInstance,
SendMessageResult,
} from '../../core/zulip/interfaces/zulip-core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
describe('ZulipService', () => {
let service: ZulipService;
@@ -158,6 +159,19 @@ describe('ZulipService', () => {
provide: 'ZULIP_CONFIG_SERVICE',
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();

View File

@@ -18,8 +18,8 @@
* - 消息格式转换和过滤
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
* @version 1.1.0
* @since 2026-01-06
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
@@ -32,6 +32,7 @@ import {
IZulipConfigService,
} from '../../core/zulip/interfaces/zulip-core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
import { LoginService } from '../auth/services/login.service';
/**
* 玩家登录请求接口
@@ -116,8 +117,12 @@ export class ZulipService {
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
private readonly apiKeySecurityService: ApiKeySecurityService,
private readonly loginService: LoginService,
) {
this.logger.log('ZulipService初始化完成');
// 启动事件处理
this.initializeEventProcessing();
}
/**
@@ -172,9 +177,7 @@ export class ZulipService {
};
}
// 2. 验证游戏Token并获取用户信息
// TODO: 实际项目中应该调用认证服务验证Token
// 这里暂时使用模拟数据
// 2. 验证游戏Token并获取用户信息 调用认证服务验证Token
const userInfo = await this.validateGameToken(request.token);
if (!userInfo) {
this.logger.warn('登录失败Token验证失败', {
@@ -288,7 +291,7 @@ export class ZulipService {
* 功能描述:
* 验证游戏Token的有效性返回用户信息
*
* @param token 游戏Token
* @param token 游戏Token (JWT)
* @returns Promise<UserInfo | null> 用户信息验证失败返回null
* @private
*/
@@ -299,69 +302,84 @@ export class ZulipService {
zulipEmail?: string;
zulipApiKey?: string;
} | null> {
// TODO: 实际项目中应该调用认证服务验证Token (登录godot所获取的JWT token)
// 这里暂时使用模拟数据进行开发测试
this.logger.debug('验证游戏Token', {
operation: 'validateGameToken',
tokenLength: token.length,
});
// 模拟Token验证
// 实际实现应该:
// 1. 调用LoginService验证Token
// 2. 从数据库获取用户的Zulip API Key
// 3. 返回完整的用户信息
if (token.startsWith('invalid')) {
return null;
}
// 从Token中提取用户ID模拟
const userId = `user_${token.substring(0, 8)}`;
// 从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;
// TODO: 从数据库获取用户的Zulip邮箱
// 暂时使用模拟数据
zulipEmail = 'angjustinl@163.com';
this.logger.log('从存储获取到Zulip API Key', {
// 1. 使用LoginService验证JWT token
const payload = await this.loginService.verifyToken(token, 'access');
if (!payload || !payload.sub) {
this.logger.warn('Token载荷无效', {
operation: 'validateGameToken',
userId,
hasApiKey: true,
zulipEmail,
});
} else {
this.logger.debug('用户没有存储的Zulip API Key', {
operation: 'validateGameToken',
userId,
});
return null;
}
} catch (error) {
const err = error as Error;
this.logger.warn('获取Zulip API Key失败', {
const userId = payload.sub;
const username = payload.username || `user_${userId}`;
const email = payload.email || `${userId}@example.com`;
this.logger.debug('Token解析成功', {
operation: 'validateGameToken',
userId,
username,
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,
};
}
/**
@@ -760,5 +778,42 @@ export class ZulipService {
async getSocketsInMap(mapId: string): Promise<string[]> {
return this.sessionManager.getSocketsInMap(mapId);
}
/**
* 获取事件处理器实例
*
* 功能描述:
* 返回ZulipEventProcessorService实例用于设置消息分发器
*
* @returns ZulipEventProcessorService 事件处理器实例
*/
getEventProcessor(): ZulipEventProcessorService {
return this.eventProcessor;
}
/**
* 初始化事件处理
*
* 功能描述:
* 启动Zulip事件处理循环用于接收和处理从Zulip服务器返回的消息
*
* @private
*/
private async initializeEventProcessing(): Promise<void> {
try {
this.logger.log('开始初始化Zulip事件处理');
// 启动事件处理循环
await this.eventProcessor.startEventProcessing();
this.logger.log('Zulip事件处理初始化完成');
} catch (error) {
const err = error as Error;
this.logger.error('初始化Zulip事件处理失败', {
operation: 'initializeEventProcessing',
error: err.message,
}, err.stack);
}
}
}

View File

@@ -139,6 +139,9 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
namespace: '/game',
timestamp: new Date().toISOString(),
});
// 设置消息分发器使ZulipEventProcessorService能够向客户端发送消息
this.setupMessageDistributor();
}
/**
@@ -373,6 +376,13 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
): Promise<void> {
const clientData = client.data as ClientData | undefined;
console.log('🔍 DEBUG: handleChat 被调用了!', {
socketId: client.id,
data: data,
clientData: clientData,
timestamp: new Date().toISOString(),
});
this.logger.log('收到聊天消息', {
operation: 'handleChat',
socketId: client.id,
@@ -749,5 +759,41 @@ export class ZulipWebSocketGateway implements OnGatewayConnection, OnGatewayDisc
});
}
}
/**
* 设置消息分发器
*
* 功能描述:
* 将当前WebSocket网关设置为ZulipEventProcessorService的消息分发器
* 使其能够接收从Zulip返回的消息并转发给游戏客户端
*
* @private
*/
private setupMessageDistributor(): void {
try {
// 获取ZulipEventProcessorService实例
const eventProcessor = this.zulipService.getEventProcessor();
if (eventProcessor) {
// 设置消息分发器
eventProcessor.setMessageDistributor(this);
this.logger.log('消息分发器设置完成', {
operation: 'setupMessageDistributor',
timestamp: new Date().toISOString(),
});
} else {
this.logger.warn('无法获取ZulipEventProcessorService实例', {
operation: 'setupMessageDistributor',
});
}
} catch (error) {
const err = error as Error;
this.logger.error('设置消息分发器失败', {
operation: 'setupMessageDistributor',
error: err.message,
}, err.stack);
}
}
}

View File

@@ -81,6 +81,9 @@ export const ThrottlePresets = {
/** 密码重置每小时3次 */
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁请1小时后再试' },
/** 令牌刷新每分钟10次 */
REFRESH_TOKEN: { limit: 10, ttl: 60, message: '令牌刷新请求过于频繁,请稍后再试' },
/** 管理员操作每分钟10次 */
ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' },

View File

@@ -5,7 +5,7 @@
* - 测试ApiKeySecurityService的核心功能
* - 包含属性测试验证API Key安全存储
*
* @author angjustinl
* @author angjustinl, moyin
* @version 1.0.0
* @since 2025-12-25
*/
@@ -548,4 +548,424 @@ describe('ApiKeySecurityService', () => {
);
}, 60000);
});
// ==================== 补充测试用例 ====================
describe('访问频率限制测试', () => {
it('应该在超过频率限制时拒绝访问', async () => {
const userId = 'rate-limit-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
// 存储API Key
await service.storeApiKey(userId, apiKey);
// 模拟已达到频率限制
const rateLimitKey = `zulip:api_key_access:${userId}`;
memoryStore.set(rateLimitKey, { value: '60' });
// 尝试获取API Key应该被拒绝
const result = await service.getApiKey(userId);
expect(result.success).toBe(false);
expect(result.message).toContain('访问频率过高');
});
it('应该正确处理频率限制计数', async () => {
const userId = 'counter-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
await service.storeApiKey(userId, apiKey);
// 连续访问多次
for (let i = 0; i < 5; i++) {
const result = await service.getApiKey(userId);
expect(result.success).toBe(true);
}
// 检查计数器
const rateLimitKey = `zulip:api_key_access:${userId}`;
const count = await mockRedisService.get(rateLimitKey);
expect(parseInt(count || '0', 10)).toBe(5);
});
it('应该在Redis错误时默认允许访问', async () => {
const userId = 'redis-error-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
await service.storeApiKey(userId, apiKey);
// 模拟Redis错误
mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed'));
mockRedisService.incr.mockRejectedValueOnce(new Error('Redis connection failed'));
// 应该仍然允许访问
const result = await service.getApiKey(userId);
expect(result.success).toBe(true);
expect(result.apiKey).toBe(apiKey);
});
});
describe('Redis错误处理测试', () => {
it('应该处理存储时的Redis错误', async () => {
mockRedisService.set.mockRejectedValueOnce(new Error('Redis connection failed'));
const result = await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
expect(result.success).toBe(false);
expect(result.message).toContain('存储失败');
});
it('应该处理获取时的Redis错误', async () => {
// 模拟所有Redis get调用都失败
mockRedisService.get.mockRejectedValue(new Error('Redis connection failed'));
const result = await service.getApiKey('user-123');
expect(result.success).toBe(false);
expect(result.message).toContain('获取失败');
});
it('应该处理删除时的Redis错误', async () => {
mockRedisService.del.mockRejectedValueOnce(new Error('Redis connection failed'));
const result = await service.deleteApiKey('user-123');
expect(result).toBe(false);
});
it('应该处理检查存在性时的Redis错误', async () => {
mockRedisService.exists.mockRejectedValueOnce(new Error('Redis connection failed'));
const result = await service.hasApiKey('user-123');
expect(result).toBe(false);
});
it('应该处理获取统计信息时的Redis错误', async () => {
mockRedisService.get.mockRejectedValueOnce(new Error('Redis connection failed'));
const stats = await service.getApiKeyStats('user-123');
expect(stats.exists).toBe(false);
});
});
describe('数据损坏处理测试', () => {
it('应该处理损坏的JSON数据', async () => {
const userId = 'corrupted-data-user';
const storageKey = `zulip:api_key:${userId}`;
// 存储损坏的JSON数据
memoryStore.set(storageKey, { value: 'invalid-json-data' });
const result = await service.getApiKey(userId);
expect(result.success).toBe(false);
expect(result.message).toContain('获取失败');
});
it('应该处理缺少必要字段的数据', async () => {
const userId = 'incomplete-data-user';
const storageKey = `zulip:api_key:${userId}`;
// 存储不完整的数据
const incompleteData = {
encryptedKey: 'some-encrypted-data',
// 缺少 iv 和 authTag
};
memoryStore.set(storageKey, { value: JSON.stringify(incompleteData) });
const result = await service.getApiKey(userId);
expect(result.success).toBe(false);
});
});
describe('可疑访问记录测试', () => {
it('应该记录可疑访问事件', async () => {
const userId = 'suspicious-user';
const reason = 'multiple_failed_attempts';
const details = { attemptCount: 5, timeWindow: '1min' };
const metadata = { ipAddress: '192.168.1.100', userAgent: 'TestAgent' };
await service.logSuspiciousAccess(userId, reason, details, metadata);
// 验证安全日志被记录
expect(mockRedisService.setex).toHaveBeenCalled();
const setexCalls = mockRedisService.setex.mock.calls;
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
expect(securityLogCall).toBeDefined();
// 验证日志内容
const logData = JSON.parse(securityLogCall![2]);
expect(logData.eventType).toBe(SecurityEventType.SUSPICIOUS_ACCESS);
expect(logData.severity).toBe(SecuritySeverity.WARNING);
expect(logData.details.reason).toBe(reason);
expect(logData.ipAddress).toBe(metadata.ipAddress);
});
});
describe('元数据处理测试', () => {
it('应该在安全日志中记录IP地址和User-Agent', async () => {
const userId = 'metadata-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
const metadata = {
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Test Browser)',
};
await service.storeApiKey(userId, apiKey, metadata);
// 验证元数据被记录在安全日志中
const setexCalls = mockRedisService.setex.mock.calls;
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
expect(securityLogCall).toBeDefined();
const logData = JSON.parse(securityLogCall![2]);
expect(logData.ipAddress).toBe(metadata.ipAddress);
expect(logData.userAgent).toBe(metadata.userAgent);
});
it('应该处理缺少元数据的情况', async () => {
const userId = 'no-metadata-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
// 不提供元数据
const result = await service.storeApiKey(userId, apiKey);
expect(result.success).toBe(true);
// 验证安全日志仍然被记录
const setexCalls = mockRedisService.setex.mock.calls;
const securityLogCall = setexCalls.find(call => call[0].includes('security_log'));
expect(securityLogCall).toBeDefined();
});
});
describe('边界条件测试', () => {
it('应该处理极长的用户ID', async () => {
const longUserId = 'a'.repeat(1000);
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
const result = await service.storeApiKey(longUserId, apiKey);
expect(result.success).toBe(true);
const getResult = await service.getApiKey(longUserId);
expect(getResult.success).toBe(true);
expect(getResult.apiKey).toBe(apiKey);
});
it('应该处理最大长度的API Key', async () => {
const userId = 'max-key-user';
const maxLengthApiKey = 'a'.repeat(128); // 最大允许长度
const result = await service.storeApiKey(userId, maxLengthApiKey);
expect(result.success).toBe(true);
const getResult = await service.getApiKey(userId);
expect(getResult.success).toBe(true);
expect(getResult.apiKey).toBe(maxLengthApiKey);
});
it('应该拒绝超长的API Key', async () => {
const userId = 'overlong-key-user';
const overlongApiKey = 'a'.repeat(129); // 超过最大长度
const result = await service.storeApiKey(userId, overlongApiKey);
expect(result.success).toBe(false);
expect(result.message).toContain('格式无效');
});
it('应该处理最小长度的API Key', async () => {
const userId = 'min-key-user';
const minLengthApiKey = 'a'.repeat(16); // 最小允许长度
const result = await service.storeApiKey(userId, minLengthApiKey);
expect(result.success).toBe(true);
const getResult = await service.getApiKey(userId);
expect(getResult.success).toBe(true);
expect(getResult.apiKey).toBe(minLengthApiKey);
});
});
describe('时间相关测试', () => {
it('应该正确设置创建时间和更新时间', async () => {
const userId = 'time-test-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
const beforeStore = new Date();
await service.storeApiKey(userId, apiKey);
const stats = await service.getApiKeyStats(userId);
expect(stats.exists).toBe(true);
expect(stats.createdAt).toBeDefined();
expect(stats.updatedAt).toBeDefined();
expect(stats.createdAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime());
expect(stats.updatedAt!.getTime()).toBeGreaterThanOrEqual(beforeStore.getTime());
});
it('应该在访问时更新最后访问时间', async () => {
const userId = 'access-time-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
await service.storeApiKey(userId, apiKey);
// 等待一小段时间
await new Promise(resolve => setTimeout(resolve, 10));
const beforeAccess = new Date();
await service.getApiKey(userId);
const stats = await service.getApiKeyStats(userId);
expect(stats.lastAccessedAt).toBeDefined();
expect(stats.lastAccessedAt!.getTime()).toBeGreaterThanOrEqual(beforeAccess.getTime());
});
it('应该在更新时保持创建时间不变', async () => {
const userId = 'update-time-user';
const oldApiKey = 'abcdefghijklmnopqrstuvwxyz123456';
const newApiKey = 'newkeyabcdefghijklmnopqrstuvwx';
await service.storeApiKey(userId, oldApiKey);
const statsAfterStore = await service.getApiKeyStats(userId);
const originalCreatedAt = statsAfterStore.createdAt;
// 等待一小段时间
await new Promise(resolve => setTimeout(resolve, 10));
await service.updateApiKey(userId, newApiKey);
const statsAfterUpdate = await service.getApiKeyStats(userId);
expect(statsAfterUpdate.createdAt).toEqual(originalCreatedAt);
expect(statsAfterUpdate.updatedAt!.getTime()).toBeGreaterThan(statsAfterStore.updatedAt!.getTime());
});
});
describe('并发访问测试', () => {
it('应该处理并发的API Key访问', async () => {
const userId = 'concurrent-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
await service.storeApiKey(userId, apiKey);
// 并发访问 - 使用串行方式来确保计数正确
const results = [];
for (let i = 0; i < 10; i++) {
const result = await service.getApiKey(userId);
results.push(result);
}
// 所有访问都应该成功
results.forEach(result => {
expect(result.success).toBe(true);
expect(result.apiKey).toBe(apiKey);
});
// 访问计数应该正确
const stats = await service.getApiKeyStats(userId);
expect(stats.accessCount).toBe(10);
});
it('应该处理并发的存储和获取操作', async () => {
const userId = 'concurrent-store-get-user';
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
// 并发执行存储和获取操作
const storePromise = service.storeApiKey(userId, apiKey);
const getPromise = service.getApiKey(userId);
const [storeResult, getResult] = await Promise.all([storePromise, getPromise]);
// 存储应该成功
expect(storeResult.success).toBe(true);
// 获取可能成功也可能失败(取决于执行顺序)
if (getResult.success) {
expect(getResult.apiKey).toBe(apiKey);
} else {
expect(getResult.message).toContain('不存在');
}
});
});
describe('安全事件记录错误处理', () => {
it('应该处理记录安全事件时的Redis错误', async () => {
mockRedisService.setex.mockRejectedValueOnce(new Error('Redis connection failed'));
// 记录安全事件不应该抛出异常
await expect(service.logSecurityEvent({
eventType: SecurityEventType.API_KEY_STORED,
severity: SecuritySeverity.INFO,
userId: 'test-user',
details: { action: 'test' },
timestamp: new Date(),
})).resolves.not.toThrow();
// 应该记录错误日志
expect(Logger.prototype.error).toHaveBeenCalledWith(
'记录安全事件失败',
expect.any(Object)
);
});
});
describe('环境变量处理测试', () => {
it('应该在没有环境变量时使用默认密钥并记录警告', () => {
// 这个测试需要在服务初始化时进行,当前实现中已经初始化了
// 验证警告日志被记录
expect(Logger.prototype.warn).toHaveBeenCalledWith(
expect.stringContaining('使用默认加密密钥')
);
});
});
describe('属性测试 - 错误处理和边界条件', () => {
/**
* 属性测试: 任何Redis错误都不应该导致服务崩溃
*/
it('任何Redis错误都不应该导致服务崩溃', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
fc.constantFrom('set', 'get', 'del', 'exists', 'setex', 'incr'),
async (userId, apiKey, failingMethod) => {
// 清理之前的数据
memoryStore.clear();
jest.clearAllMocks();
// 模拟特定方法失败
(mockRedisService as any)[failingMethod].mockRejectedValueOnce(
new Error(`${failingMethod} failed`)
);
// 执行操作不应该抛出异常
await expect(service.storeApiKey(userId.trim(), apiKey)).resolves.not.toThrow();
await expect(service.getApiKey(userId.trim())).resolves.not.toThrow();
await expect(service.deleteApiKey(userId.trim())).resolves.not.toThrow();
await expect(service.hasApiKey(userId.trim())).resolves.not.toThrow();
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性测试: 任何输入都不应该导致服务崩溃
*/
it('任何输入都不应该导致服务崩溃', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ maxLength: 1000 }), // 任意字符串作为用户ID
fc.string({ maxLength: 1000 }), // 任意字符串作为API Key
async (userId, apiKey) => {
// 清理之前的数据
memoryStore.clear();
// 任何输入都不应该导致崩溃
await expect(service.storeApiKey(userId, apiKey)).resolves.not.toThrow();
await expect(service.getApiKey(userId)).resolves.not.toThrow();
await expect(service.updateApiKey(userId, apiKey)).resolves.not.toThrow();
await expect(service.deleteApiKey(userId)).resolves.not.toThrow();
await expect(service.hasApiKey(userId)).resolves.not.toThrow();
await expect(service.getApiKeyStats(userId)).resolves.not.toThrow();
}
),
{ numRuns: 100 }
);
}, 60000);
});
});

View File

@@ -23,7 +23,7 @@
* - AppLoggerService: 日志记录服务
* - IRedisService: Redis缓存服务
*
* @author angjustinl
* @author angjustinl, moyin
* @version 1.0.0
* @since 2025-12-25
*/

View File

@@ -5,7 +5,7 @@
* - 测试ConfigManagerService的核心功能
* - 包含属性测试验证配置验证正确性
*
* @author angjustinl
* @author angjustinl, moyin
* @version 1.0.0
* @since 2025-12-25
*/
@@ -60,6 +60,13 @@ describe('ConfigManagerService', () => {
beforeEach(async () => {
jest.clearAllMocks();
// 设置测试环境变量
process.env.NODE_ENV = 'test';
process.env.ZULIP_SERVER_URL = 'https://test-zulip.com';
process.env.ZULIP_BOT_EMAIL = 'test-bot@test.com';
process.env.ZULIP_BOT_API_KEY = 'test-api-key';
process.env.ZULIP_API_KEY_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
@@ -88,6 +95,12 @@ describe('ConfigManagerService', () => {
afterEach(() => {
jest.restoreAllMocks();
// 清理环境变量
delete process.env.NODE_ENV;
delete process.env.ZULIP_SERVER_URL;
delete process.env.ZULIP_BOT_EMAIL;
delete process.env.ZULIP_BOT_API_KEY;
delete process.env.ZULIP_API_KEY_ENCRYPTION_KEY;
});
it('should be defined', () => {
@@ -595,4 +608,504 @@ describe('ConfigManagerService', () => {
);
}, 60000);
});
// ==================== 补充测试用例 ====================
describe('hasMap - 检查地图是否存在', () => {
it('应该返回true当地图存在时', () => {
const exists = service.hasMap('novice_village');
expect(exists).toBe(true);
});
it('应该返回false当地图不存在时', () => {
const exists = service.hasMap('nonexistent');
expect(exists).toBe(false);
});
it('应该处理空字符串输入', () => {
const exists = service.hasMap('');
expect(exists).toBe(false);
});
it('应该处理null/undefined输入', () => {
const exists1 = service.hasMap(null as any);
const exists2 = service.hasMap(undefined as any);
expect(exists1).toBe(false);
expect(exists2).toBe(false);
});
});
describe('getAllMapIds - 获取所有地图ID', () => {
it('应该返回所有地图ID列表', () => {
const mapIds = service.getAllMapIds();
expect(mapIds).toContain('novice_village');
expect(mapIds).toContain('tavern');
expect(mapIds.length).toBe(2);
});
});
describe('getMapConfigByStream - 根据Stream获取地图配置', () => {
it('应该返回正确的地图配置', () => {
const config = service.getMapConfigByStream('Novice Village');
expect(config).toBeDefined();
expect(config?.mapId).toBe('novice_village');
});
it('应该支持大小写不敏感查询', () => {
const config = service.getMapConfigByStream('novice village');
expect(config).toBeDefined();
expect(config?.mapId).toBe('novice_village');
});
it('应该在Stream不存在时返回null', () => {
const config = service.getMapConfigByStream('nonexistent');
expect(config).toBeNull();
});
});
describe('getAllStreams - 获取所有Stream名称', () => {
it('应该返回所有Stream名称列表', () => {
const streams = service.getAllStreams();
expect(streams).toContain('Novice Village');
expect(streams).toContain('Tavern');
expect(streams.length).toBe(2);
});
});
describe('hasStream - 检查Stream是否存在', () => {
it('应该返回true当Stream存在时', () => {
const exists = service.hasStream('Novice Village');
expect(exists).toBe(true);
});
it('应该支持大小写不敏感查询', () => {
const exists = service.hasStream('novice village');
expect(exists).toBe(true);
});
it('应该返回false当Stream不存在时', () => {
const exists = service.hasStream('nonexistent');
expect(exists).toBe(false);
});
it('应该处理空字符串输入', () => {
const exists = service.hasStream('');
expect(exists).toBe(false);
});
});
describe('findObjectByTopic - 根据Topic查找交互对象', () => {
it('应该找到正确的交互对象', () => {
const obj = service.findObjectByTopic('Notice Board');
expect(obj).toBeDefined();
expect(obj?.objectId).toBe('notice_board');
expect(obj?.mapId).toBe('novice_village');
});
it('应该支持大小写不敏感查询', () => {
const obj = service.findObjectByTopic('notice board');
expect(obj).toBeDefined();
expect(obj?.objectId).toBe('notice_board');
});
it('应该在Topic不存在时返回null', () => {
const obj = service.findObjectByTopic('nonexistent');
expect(obj).toBeNull();
});
it('应该处理空字符串输入', () => {
const obj = service.findObjectByTopic('');
expect(obj).toBeNull();
});
});
describe('getObjectsInMap - 获取地图中的所有交互对象', () => {
it('应该返回地图中的所有交互对象', () => {
const objects = service.getObjectsInMap('novice_village');
expect(objects.length).toBe(1);
expect(objects[0].objectId).toBe('notice_board');
expect(objects[0].mapId).toBe('novice_village');
});
it('应该在地图不存在时返回空数组', () => {
const objects = service.getObjectsInMap('nonexistent');
expect(objects).toEqual([]);
});
});
describe('getConfigFilePath - 获取配置文件路径', () => {
it('应该返回正确的配置文件路径', () => {
const filePath = service.getConfigFilePath();
expect(filePath).toContain('map-config.json');
});
});
describe('configFileExists - 检查配置文件是否存在', () => {
it('应该返回true当配置文件存在时', () => {
mockFs.existsSync.mockReturnValue(true);
const exists = service.configFileExists();
expect(exists).toBe(true);
});
it('应该返回false当配置文件不存在时', () => {
mockFs.existsSync.mockReturnValue(false);
const exists = service.configFileExists();
expect(exists).toBe(false);
});
});
describe('reloadConfig - 热重载配置', () => {
it('应该成功重载配置', async () => {
await expect(service.reloadConfig()).resolves.not.toThrow();
});
it('应该在配置文件读取失败时抛出错误', async () => {
mockFs.readFileSync.mockImplementation(() => {
throw new Error('File read error');
});
await expect(service.reloadConfig()).rejects.toThrow();
});
});
describe('getZulipConfig - 获取Zulip配置', () => {
it('应该返回Zulip配置对象', () => {
const config = service.getZulipConfig();
expect(config).toBeDefined();
expect(config.zulipServerUrl).toBeDefined();
expect(config.websocketPort).toBeDefined();
});
});
describe('getAllMapConfigs - 获取所有地图配置', () => {
it('应该返回所有地图配置列表', () => {
const configs = service.getAllMapConfigs();
expect(configs.length).toBe(2);
expect(configs.some(c => c.mapId === 'novice_village')).toBe(true);
expect(configs.some(c => c.mapId === 'tavern')).toBe(true);
});
});
describe('配置文件监听功能', () => {
let mockWatcher: any;
beforeEach(() => {
mockWatcher = {
close: jest.fn(),
};
(fs.watch as jest.Mock).mockReturnValue(mockWatcher);
});
describe('enableConfigWatcher - 启用配置文件监听', () => {
it('应该成功启用配置文件监听', () => {
const result = service.enableConfigWatcher();
expect(result).toBe(true);
expect(fs.watch).toHaveBeenCalled();
});
it('应该在配置文件不存在时返回false', () => {
mockFs.existsSync.mockReturnValue(false);
const result = service.enableConfigWatcher();
expect(result).toBe(false);
});
it('应该在已启用时跳过重复启用', () => {
service.enableConfigWatcher();
(fs.watch as jest.Mock).mockClear();
const result = service.enableConfigWatcher();
expect(result).toBe(true);
expect(fs.watch).not.toHaveBeenCalled();
});
it('应该处理fs.watch抛出的错误', () => {
(fs.watch as jest.Mock).mockImplementation(() => {
throw new Error('Watch error');
});
const result = service.enableConfigWatcher();
expect(result).toBe(false);
});
});
describe('disableConfigWatcher - 禁用配置文件监听', () => {
it('应该成功禁用配置文件监听', () => {
service.enableConfigWatcher();
service.disableConfigWatcher();
expect(mockWatcher.close).toHaveBeenCalled();
});
it('应该处理未启用监听的情况', () => {
// 不应该抛出错误
expect(() => service.disableConfigWatcher()).not.toThrow();
});
});
describe('isConfigWatcherEnabled - 检查监听状态', () => {
it('应该返回正确的监听状态', () => {
expect(service.isConfigWatcherEnabled()).toBe(false);
service.enableConfigWatcher();
expect(service.isConfigWatcherEnabled()).toBe(true);
service.disableConfigWatcher();
expect(service.isConfigWatcherEnabled()).toBe(false);
});
});
});
describe('getFullConfiguration - 获取完整配置', () => {
it('应该返回完整的配置对象', () => {
const config = service.getFullConfiguration();
expect(config).toBeDefined();
});
});
describe('updateConfigValue - 更新配置值', () => {
it('应该成功更新有效的配置值', () => {
// 这个测试需要模拟fullConfig存在
const result = service.updateConfigValue('message.rateLimit', 20);
// 由于测试环境中fullConfig可能未初始化这里主要测试不抛出异常
expect(typeof result).toBe('boolean');
});
it('应该在配置键不存在时返回false', () => {
const result = service.updateConfigValue('nonexistent.key', 'value');
expect(result).toBe(false);
});
it('应该处理无效的键路径', () => {
const result = service.updateConfigValue('', 'value');
expect(result).toBe(false);
});
});
describe('exportMapConfig - 导出地图配置', () => {
it('应该成功导出配置到文件', () => {
const result = service.exportMapConfig();
expect(result).toBe(true);
expect(mockFs.writeFileSync).toHaveBeenCalled();
});
it('应该处理文件写入错误', () => {
mockFs.writeFileSync.mockImplementation(() => {
throw new Error('Write error');
});
const result = service.exportMapConfig();
expect(result).toBe(false);
});
it('应该支持自定义文件路径', () => {
const customPath = '/custom/path/config.json';
const result = service.exportMapConfig(customPath);
expect(result).toBe(true);
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
customPath,
expect.any(String),
'utf-8'
);
});
});
describe('错误处理测试', () => {
it('应该处理JSON解析错误', async () => {
mockFs.readFileSync.mockReturnValue('invalid json');
await expect(service.loadMapConfig()).rejects.toThrow();
});
it('应该处理文件系统错误', async () => {
mockFs.readFileSync.mockImplementation(() => {
throw new Error('File system error');
});
await expect(service.loadMapConfig()).rejects.toThrow();
});
it('应该处理配置验证过程中的错误', async () => {
// 模拟验证过程中抛出异常
const originalValidateMapConfig = (service as any).validateMapConfig;
(service as any).validateMapConfig = jest.fn().mockImplementation(() => {
throw new Error('Validation error');
});
const result = await service.validateConfig();
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('验证过程出错'))).toBe(true);
// 恢复原方法
(service as any).validateMapConfig = originalValidateMapConfig;
});
});
describe('边界条件测试', () => {
it('应该处理空的地图配置', async () => {
const emptyConfig = { maps: [] };
mockFs.readFileSync.mockReturnValue(JSON.stringify(emptyConfig));
await service.loadMapConfig();
const mapIds = service.getAllMapIds();
expect(mapIds).toEqual([]);
});
it('应该处理大量地图配置', async () => {
const largeConfig = {
maps: Array.from({ length: 1000 }, (_, i) => ({
mapId: `map_${i}`,
mapName: `地图${i}`,
zulipStream: `Stream${i}`,
interactionObjects: []
}))
};
mockFs.readFileSync.mockReturnValue(JSON.stringify(largeConfig));
await service.loadMapConfig();
const mapIds = service.getAllMapIds();
expect(mapIds.length).toBe(1000);
});
it('应该处理极长的字符串输入', () => {
const longString = 'a'.repeat(10000);
const stream = service.getStreamByMap(longString);
expect(stream).toBeNull();
});
it('应该处理特殊字符输入', () => {
const specialChars = '!@#$%^&*()[]{}|;:,.<>?';
const stream = service.getStreamByMap(specialChars);
expect(stream).toBeNull();
});
});
describe('并发操作测试', () => {
it('应该处理并发的配置查询', async () => {
const promises = Array.from({ length: 100 }, () =>
Promise.resolve(service.getStreamByMap('novice_village'))
);
const results = await Promise.all(promises);
results.forEach(result => {
expect(result).toBe('Novice Village');
});
});
it('应该处理并发的配置重载', async () => {
const promises = Array.from({ length: 10 }, () => service.reloadConfig());
// 不应该抛出异常
await expect(Promise.all(promises)).resolves.not.toThrow();
});
});
describe('内存管理测试', () => {
it('应该正确清理资源', () => {
service.enableConfigWatcher();
// 模拟模块销毁
service.onModuleDestroy();
expect(service.isConfigWatcherEnabled()).toBe(false);
});
});
describe('属性测试 - 配置查询一致性', () => {
/**
* 属性测试: 配置查询的一致性
* 验证双向查询的一致性mapId <-> stream
*/
it('mapId和stream之间的双向查询应该保持一致', async () => {
await fc.assert(
fc.asyncProperty(
// 从现有的mapId中选择
fc.constantFrom('novice_village', 'tavern'),
async (mapId) => {
// 通过mapId获取stream
const stream = service.getStreamByMap(mapId);
expect(stream).not.toBeNull();
// 通过stream反向获取mapId
const retrievedMapId = service.getMapIdByStream(stream!);
expect(retrievedMapId).toBe(mapId);
// 通过stream获取配置
const config = service.getMapConfigByStream(stream!);
expect(config).not.toBeNull();
expect(config!.mapId).toBe(mapId);
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性测试: 交互对象查询的一致性
*/
it('交互对象的不同查询方式应该返回一致的结果', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom('novice_village', 'tavern'),
async (mapId) => {
// 获取地图中的所有对象
const objectsInMap = service.getObjectsInMap(mapId);
for (const obj of objectsInMap) {
// 通过topic查找对象
const objByTopic = service.findObjectByTopic(obj.zulipTopic);
expect(objByTopic).not.toBeNull();
expect(objByTopic!.objectId).toBe(obj.objectId);
expect(objByTopic!.mapId).toBe(mapId);
// 通过mapId和objectId获取topic
const topic = service.getTopicByObject(mapId, obj.objectId);
expect(topic).toBe(obj.zulipTopic);
}
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性测试: 配置验证的幂等性
*/
it('配置验证应该是幂等的', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
mapId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
mapName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
zulipStream: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
interactionObjects: fc.array(
fc.record({
objectId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
objectName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
zulipTopic: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
position: fc.record({
x: fc.integer({ min: 0, max: 10000 }),
y: fc.integer({ min: 0, max: 10000 }),
}),
}),
{ maxLength: 5 }
),
}),
async (config) => {
// 多次验证同一个配置应该返回相同结果
const result1 = service.validateMapConfigDetailed(config);
const result2 = service.validateMapConfigDetailed(config);
const result3 = service.validateMapConfigDetailed(config);
expect(result1.valid).toBe(result2.valid);
expect(result2.valid).toBe(result3.valid);
expect(result1.errors).toEqual(result2.errors);
expect(result2.errors).toEqual(result3.errors);
}
),
{ numRuns: 100 }
);
}, 60000);
});
});

View File

@@ -26,7 +26,7 @@
* 依赖模块:
* - AppLoggerService: 日志记录服务
*
* @author angjustinl
* @author angjustinl, moyin
* @version 1.0.0
* @since 2025-12-25
*/

View File

@@ -8,7 +8,7 @@
* **Feature: zulip-integration, Property 9: 错误处理和服务降级**
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
*
* @author angjustinl
* @author angjustinl, moyin
* @version 1.0.0
* @since 2025-12-25
*/
@@ -570,4 +570,457 @@ describe('ErrorHandlerService', () => {
);
}, 30000);
});
// ==================== 补充测试用例 ====================
describe('错误统计测试', () => {
it('应该正确记录和获取错误统计', async () => {
// 触发几个不同类型的错误
await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth');
await service.handleZulipError({ code: 429, message: 'Rate limit' }, 'rate');
await service.handleZulipError({ code: 401, message: 'Unauthorized' }, 'auth2');
const stats = service.getErrorStats();
expect(stats.serviceStatus).toBeDefined();
expect(stats.errorCounts).toBeDefined();
expect(stats.recentErrors).toBeDefined();
// 应该有认证错误和频率限制错误的记录
expect(Object.keys(stats.errorCounts).length).toBeGreaterThan(0);
});
it('应该能够重置错误统计', async () => {
// 先产生一些错误
await service.handleZulipError({ code: 500, message: 'Server error' }, 'test');
service.resetErrorStats();
const stats = service.getErrorStats();
expect(Object.keys(stats.errorCounts)).toHaveLength(0);
expect(Object.keys(stats.recentErrors)).toHaveLength(0);
});
});
describe('服务健康检查测试', () => {
it('应该返回完整的健康状态信息', async () => {
const health = await service.checkServiceHealth();
expect(health.status).toBeDefined();
expect(health.details).toBeDefined();
expect(health.details.serviceStatus).toBeDefined();
expect(health.details.errorCounts).toBeDefined();
expect(health.details.lastErrors).toBeDefined();
});
it('应该在降级模式下返回正确状态', async () => {
await service.enableDegradedMode();
const health = await service.checkServiceHealth();
expect(health.status).toBe(ServiceStatus.DEGRADED);
expect(health.details.degradedModeStartTime).toBeDefined();
});
});
describe('配置获取测试', () => {
it('应该返回正确的配置信息', () => {
const config = service.getConfig();
expect(config.degradedModeEnabled).toBe(true);
expect(config.autoReconnectEnabled).toBe(true);
expect(config.maxReconnectAttempts).toBe(5);
expect(config.reconnectBaseDelay).toBe(1000);
expect(config.apiTimeout).toBe(30000);
expect(config.maxRetries).toBe(3);
expect(config.maxConnections).toBe(1000);
});
it('应该返回正确的单个配置项', () => {
expect(service.isDegradedModeEnabled()).toBe(true);
expect(service.isAutoReconnectEnabled()).toBe(true);
expect(service.getApiTimeout()).toBe(30000);
expect(service.getMaxRetries()).toBe(3);
expect(service.getMaxReconnectAttempts()).toBe(5);
expect(service.getReconnectBaseDelay()).toBe(1000);
});
it('应该返回默认重试配置', () => {
const retryConfig = service.getDefaultRetryConfig();
expect(retryConfig.maxRetries).toBe(3);
expect(retryConfig.baseDelay).toBe(1000);
expect(retryConfig.maxDelay).toBe(30000);
expect(retryConfig.backoffMultiplier).toBe(2);
});
});
describe('状态检查方法测试', () => {
it('应该正确检查服务可用性', () => {
expect(service.isServiceAvailable()).toBe(true);
// 设置为不可用状态(通过私有属性)
(service as any).serviceStatus = ServiceStatus.UNAVAILABLE;
expect(service.isServiceAvailable()).toBe(false);
});
it('应该正确检查降级模式状态', async () => {
expect(service.isDegradedMode()).toBe(false);
await service.enableDegradedMode();
expect(service.isDegradedMode()).toBe(true);
await service.enableNormalMode();
expect(service.isDegradedMode()).toBe(false);
});
});
describe('连接数管理测试', () => {
it('应该能够设置最大连接数', () => {
service.setMaxConnections(500);
expect(service.getConfig().maxConnections).toBe(500);
});
it('应该正确处理负连接数变化', () => {
service.updateActiveConnections(100);
service.updateActiveConnections(-150); // 应该不会变成负数
// 活跃连接数不应该小于0
const loadStatus = service.getLoadStatus();
expect(loadStatus).toBeDefined();
});
it('应该在连接数达到上限时限制新连接', () => {
service.setMaxConnections(100);
service.updateActiveConnections(100);
expect(service.shouldLimitNewConnections()).toBe(true);
});
});
describe('带超时和重试的操作执行测试', () => {
it('应该成功执行带超时和重试的操作', async () => {
const operation = jest.fn().mockResolvedValue('success');
const result = await service.executeWithTimeoutAndRetry(
operation,
{ timeout: 1000, operation: 'test' },
{ maxRetries: 2, baseDelay: 10 }
);
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(1);
});
it('应该在超时后重试', async () => {
let callCount = 0;
const operation = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return new Promise(resolve => setTimeout(() => resolve('late'), 200));
}
return Promise.resolve('success');
});
const result = await service.executeWithTimeoutAndRetry(
operation,
{ timeout: 50, operation: 'test' },
{ maxRetries: 2, baseDelay: 10 }
);
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(2);
});
});
describe('重连状态管理测试', () => {
it('应该正确获取重连状态', async () => {
const reconnectCallback = jest.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(false), 100))
);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 3,
baseDelay: 50,
});
const state = service.getReconnectState('user1');
expect(state).toBeDefined();
expect(state?.userId).toBe('user1');
expect(state?.isReconnecting).toBe(true);
// 清理
service.cancelReconnect('user1');
});
it('应该在重连失败达到最大次数后停止', async () => {
const reconnectCallback = jest.fn().mockResolvedValue(false);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 2,
baseDelay: 10,
});
// 等待重连尝试完成
await new Promise(resolve => setTimeout(resolve, 200));
// 应该尝试了最大次数
expect(reconnectCallback).toHaveBeenCalledTimes(2);
// 重连状态应该被清理
expect(service.getReconnectState('user1')).toBeNull();
});
it('应该处理重连回调异常', async () => {
const reconnectCallback = jest.fn().mockRejectedValue(new Error('Reconnect failed'));
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 2,
baseDelay: 10,
});
// 等待重连尝试完成
await new Promise(resolve => setTimeout(resolve, 200));
// 应该尝试了最大次数
expect(reconnectCallback).toHaveBeenCalledTimes(2);
// 重连状态应该被清理
expect(service.getReconnectState('user1')).toBeNull();
});
it('应该在已有重连进行时跳过新的重连调度', async () => {
const reconnectCallback1 = jest.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(false), 200))
);
const reconnectCallback2 = jest.fn().mockResolvedValue(true);
// 第一次调度
const scheduled1 = await service.scheduleReconnect({
userId: 'user1',
reconnectCallback: reconnectCallback1,
maxAttempts: 3,
baseDelay: 50,
});
// 第二次调度(应该被跳过)
const scheduled2 = await service.scheduleReconnect({
userId: 'user1',
reconnectCallback: reconnectCallback2,
maxAttempts: 3,
baseDelay: 50,
});
expect(scheduled1).toBe(true);
expect(scheduled2).toBe(false);
expect(reconnectCallback2).not.toHaveBeenCalled();
// 清理
service.cancelReconnect('user1');
});
});
describe('事件发射测试', () => {
it('应该在启用降级模式时发射事件', async () => {
const eventListener = jest.fn();
service.on('degraded_mode_enabled', eventListener);
await service.enableDegradedMode();
expect(eventListener).toHaveBeenCalledWith({
startTime: expect.any(Date),
});
});
it('应该在重连成功时发射事件', async () => {
const eventListener = jest.fn();
service.on('reconnect_success', eventListener);
const reconnectCallback = jest.fn().mockResolvedValue(true);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 3,
baseDelay: 10,
});
// 等待重连完成
await new Promise(resolve => setTimeout(resolve, 100));
expect(eventListener).toHaveBeenCalledWith({
userId: 'user1',
attempts: 1,
});
});
it('应该在重连失败时发射事件', async () => {
const eventListener = jest.fn();
service.on('reconnect_failed', eventListener);
const reconnectCallback = jest.fn().mockResolvedValue(false);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 1,
baseDelay: 10,
});
// 等待重连完成
await new Promise(resolve => setTimeout(resolve, 100));
expect(eventListener).toHaveBeenCalledWith({
userId: 'user1',
attempts: 1,
});
});
});
describe('错误处理边界条件测试', () => {
it('应该处理空错误对象', async () => {
const result = await service.handleZulipError(null, 'test');
expect(result.success).toBe(false);
expect(result.shouldRetry).toBe(false);
});
it('应该处理没有code和message的错误', async () => {
const result = await service.handleZulipError({}, 'test');
expect(result.success).toBe(false);
expect(result.message).toBeDefined();
});
it('应该处理错误处理过程中的异常', async () => {
// 模拟错误分类过程中的异常
const originalClassifyError = (service as any).classifyError;
(service as any).classifyError = jest.fn().mockImplementation(() => {
throw new Error('Classification error');
});
const result = await service.handleZulipError({ code: 500 }, 'test');
expect(result.success).toBe(false);
expect(result.message).toContain('错误处理失败');
// 恢复原方法
(service as any).classifyError = originalClassifyError;
});
});
describe('模块销毁测试', () => {
it('应该正确清理所有资源', async () => {
// 设置一些状态
await service.enableDegradedMode();
const reconnectCallback = jest.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(false), 1000))
);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 5,
baseDelay: 100,
});
// 销毁模块
await service.onModuleDestroy();
// 验证资源被清理
expect(service.getReconnectState('user1')).toBeNull();
});
});
describe('并发操作测试', () => {
it('应该处理并发的错误处理请求', async () => {
const errors = [
{ code: 401, message: 'Unauthorized' },
{ code: 429, message: 'Rate limit' },
{ code: 500, message: 'Server error' },
{ code: 'ECONNREFUSED', message: 'Connection refused' },
];
const promises = errors.map((error, index) =>
service.handleZulipError(error, `operation${index}`)
);
const results = await Promise.all(promises);
// 所有请求都应该得到处理
expect(results).toHaveLength(4);
results.forEach(result => {
expect(result).toBeDefined();
expect(typeof result.success).toBe('boolean');
expect(typeof result.shouldRetry).toBe('boolean');
});
});
it('应该处理并发的重连请求', async () => {
const users = ['user1', 'user2', 'user3'];
const reconnectCallback = jest.fn().mockResolvedValue(true);
const promises = users.map(userId =>
service.scheduleReconnect({
userId,
reconnectCallback,
maxAttempts: 2,
baseDelay: 10,
})
);
const results = await Promise.all(promises);
// 所有重连都应该被调度
expect(results.every(r => r === true)).toBe(true);
// 等待重连完成
await new Promise(resolve => setTimeout(resolve, 100));
// 验证所有用户的重连状态都被清理
users.forEach(userId => {
expect(service.getReconnectState(userId)).toBeNull();
});
});
});
describe('性能测试', () => {
it('应该能够处理大量错误而不影响性能', async () => {
const startTime = Date.now();
// 处理100个错误
const promises = Array.from({ length: 100 }, (_, i) =>
service.handleZulipError({ code: 500, message: `Error ${i}` }, `op${i}`)
);
await Promise.all(promises);
const elapsed = Date.now() - startTime;
// 应该在合理时间内完成比如1秒
expect(elapsed).toBeLessThan(1000);
});
it('应该能够处理大量连接数更新', () => {
const startTime = Date.now();
// 更新1000次连接数
for (let i = 0; i < 1000; i++) {
service.updateActiveConnections(1);
}
const elapsed = Date.now() - startTime;
// 应该在合理时间内完成
expect(elapsed).toBeLessThan(100);
});
});
});

View File

@@ -24,7 +24,7 @@
* 依赖模块:
* - AppLoggerService: 日志记录服务
*
* @author angjustinl
* @author angjustinl, moyin
* @version 1.0.0
* @since 2025-12-25
*/
@@ -239,8 +239,8 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
this.logger.warn('处理Zulip API错误', {
operation: 'handleZulipError',
targetOperation: operation,
errorMessage: error.message,
errorCode: error.code,
errorMessage: error?.message || 'Unknown error',
errorCode: error?.code || 'UNKNOWN',
timestamp: new Date().toISOString(),
});
@@ -269,7 +269,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
this.logger.error('错误处理过程中发生异常', {
operation: 'handleZulipError',
targetOperation: operation,
originalError: error.message,
originalError: error?.message || 'Unknown error',
handlingError: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
@@ -438,7 +438,7 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
this.logger.warn('处理连接错误', {
operation: 'handleConnectionError',
connectionType,
errorMessage: error.message,
errorMessage: error?.message || 'Unknown connection error',
timestamp: new Date().toISOString(),
});
@@ -632,16 +632,16 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
case ErrorType.ZULIP_API_ERROR:
return {
success: false,
shouldRetry: error.code >= 500, // 服务器错误可以重试
shouldRetry: error?.code >= 500, // 服务器错误可以重试
retryAfter: 2000,
message: `Zulip API错误: ${error.message}`,
message: `Zulip API错误: ${error?.message || 'Unknown API error'}`,
};
default:
return {
success: false,
shouldRetry: false,
message: `未知错误: ${error.message}`,
message: `未知错误: ${error?.message || 'Unknown error'}`,
};
}
}

View File

@@ -12,7 +12,7 @@
* **Feature: zulip-integration, Property 11: 系统监控和告警**
* **Validates: Requirements 9.4**
*
* @author angjustinl
* @author angjustinl moyin
* @version 1.0.0
* @since 2025-12-25
*/
@@ -730,4 +730,362 @@ describe('MonitoringService', () => {
);
}, 30000);
});
// ==================== 补充测试用例 ====================
describe('边界条件和错误处理测试', () => {
it('应该正确处理活跃连接数不会变成负数', () => {
// 先断开一个不存在的连接
service.logConnection({
socketId: 'socket1',
eventType: ConnectionEventType.DISCONNECTED,
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.connections.active).toBe(0); // 不应该是负数
});
it('应该正确处理空的最近告警列表', () => {
const alerts = service.getRecentAlerts(5);
expect(alerts).toEqual([]);
});
it('应该正确处理超出限制的最近告警请求', () => {
// 添加一些告警
for (let i = 0; i < 5; i++) {
service.sendAlert({
id: `alert-${i}`,
level: AlertLevel.INFO,
title: `Alert ${i}`,
message: `Message ${i}`,
component: 'test',
timestamp: new Date(),
});
}
// 请求超过实际数量的告警
const alerts = service.getRecentAlerts(10);
expect(alerts.length).toBe(5);
});
it('应该正确处理零除法情况', () => {
// 在没有任何API调用时获取统计
const stats = service.getStats();
expect(stats.apiCalls.avgResponseTime).toBe(0); // 应该是0而不是NaN
});
it('应该正确处理消息延迟统计的零除法', () => {
// 在没有任何消息时获取统计
const stats = service.getStats();
expect(stats.messages.avgLatency).toBe(0); // 应该是0而不是NaN
});
it('应该正确处理不同级别的告警日志', () => {
const logSpy = jest.spyOn(Logger.prototype, 'log');
const warnSpy = jest.spyOn(Logger.prototype, 'warn');
const errorSpy = jest.spyOn(Logger.prototype, 'error');
// INFO级别
service.sendAlert({
id: 'info-alert',
level: AlertLevel.INFO,
title: 'Info Alert',
message: 'Info message',
component: 'test',
timestamp: new Date(),
});
// WARNING级别
service.sendAlert({
id: 'warn-alert',
level: AlertLevel.WARNING,
title: 'Warning Alert',
message: 'Warning message',
component: 'test',
timestamp: new Date(),
});
// ERROR级别
service.sendAlert({
id: 'error-alert',
level: AlertLevel.ERROR,
title: 'Error Alert',
message: 'Error message',
component: 'test',
timestamp: new Date(),
});
// CRITICAL级别
service.sendAlert({
id: 'critical-alert',
level: AlertLevel.CRITICAL,
title: 'Critical Alert',
message: 'Critical message',
component: 'test',
timestamp: new Date(),
});
expect(logSpy).toHaveBeenCalled(); // INFO
expect(warnSpy).toHaveBeenCalled(); // WARNING
expect(errorSpy).toHaveBeenCalledTimes(2); // ERROR + CRITICAL
});
it('应该正确处理健康检查中的降级状态', async () => {
// 先添加一些正常连接
for (let i = 0; i < 10; i++) {
service.logConnection({
socketId: `socket-normal-${i}`,
eventType: ConnectionEventType.CONNECTED,
timestamp: new Date(),
});
}
// 然后添加一些错误来触发降级状态
for (let i = 0; i < 5; i++) {
service.logConnection({
socketId: `socket-error-${i}`,
eventType: ConnectionEventType.ERROR,
timestamp: new Date(),
});
}
const health = await service.checkSystemHealth();
// 错误率应该是 5/10 = 50%,超过阈值 10%,应该是不健康状态
expect(health.components.websocket.status).toMatch(/^(degraded|unhealthy)$/);
});
it('应该正确处理API调用的降级状态', async () => {
// 先添加一些成功的API调用
for (let i = 0; i < 10; i++) {
service.logApiCall({
operation: 'test',
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 100,
timestamp: new Date(),
});
}
// 然后模拟大量失败的API调用
for (let i = 0; i < 5; i++) {
service.logApiCall({
operation: 'test',
userId: 'user1',
result: ApiCallResult.FAILURE,
responseTime: 100,
timestamp: new Date(),
});
}
const health = await service.checkSystemHealth();
// 错误率应该是 5/15 = 33%,超过阈值 10%,应该是不健康状态
expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/);
});
it('应该正确处理慢API调用的降级状态', async () => {
// 模拟慢API调用
for (let i = 0; i < 10; i++) {
service.logApiCall({
operation: 'test',
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 15000, // 超过阈值
timestamp: new Date(),
});
}
const health = await service.checkSystemHealth();
// 应该检测到API组件降级
expect(health.components.zulipApi.status).toMatch(/^(degraded|unhealthy)$/);
});
it('应该正确处理模块生命周期', () => {
// 测试模块初始化
service.onModuleInit();
// 验证健康检查已启动(通过检查私有属性)
expect((service as any).healthCheckInterval).toBeDefined();
// 测试模块销毁
service.onModuleDestroy();
// 验证健康检查已停止
expect((service as any).healthCheckInterval).toBeNull();
});
it('应该正确处理最近日志的大小限制', () => {
const maxLogs = (service as any).maxRecentLogs;
// 添加超过限制的API调用日志
for (let i = 0; i < maxLogs + 10; i++) {
service.logApiCall({
operation: `test-${i}`,
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 100,
timestamp: new Date(),
});
}
// 验证最近日志数量不超过限制
const recentLogs = (service as any).recentApiCalls;
expect(recentLogs.length).toBeLessThanOrEqual(maxLogs);
});
it('应该正确处理最近告警的大小限制', () => {
const maxLogs = (service as any).maxRecentLogs;
// 添加超过限制的告警
for (let i = 0; i < maxLogs + 10; i++) {
service.sendAlert({
id: `alert-${i}`,
level: AlertLevel.INFO,
title: `Alert ${i}`,
message: `Message ${i}`,
component: 'test',
timestamp: new Date(),
});
}
// 验证最近告警数量不超过限制
const recentAlerts = (service as any).recentAlerts;
expect(recentAlerts.length).toBeLessThanOrEqual(maxLogs);
});
it('应该正确处理消息转发错误统计', () => {
service.logMessageForward({
fromUserId: 'user1',
toUserIds: ['user2'],
stream: 'test-stream',
topic: 'test-topic',
direction: 'upstream',
success: false, // 失败的消息
latency: 100,
error: 'Transfer failed',
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.messages.errors).toBe(1);
});
it('应该正确处理带有元数据的连接日志', () => {
const eventHandler = jest.fn();
service.on('connection_event', eventHandler);
service.logConnection({
socketId: 'socket1',
userId: 'user1',
eventType: ConnectionEventType.CONNECTED,
duration: 1000,
timestamp: new Date(),
metadata: { userAgent: 'test-agent', ip: '127.0.0.1' },
});
expect(eventHandler).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { userAgent: 'test-agent', ip: '127.0.0.1' },
})
);
service.removeListener('connection_event', eventHandler);
});
it('应该正确处理带有元数据的API调用日志', () => {
const eventHandler = jest.fn();
service.on('api_call', eventHandler);
service.logApiCall({
operation: 'sendMessage',
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 100,
statusCode: 200,
timestamp: new Date(),
metadata: { endpoint: '/api/messages', method: 'POST' },
});
expect(eventHandler).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { endpoint: '/api/messages', method: 'POST' },
})
);
service.removeListener('api_call', eventHandler);
});
it('应该正确处理带有消息ID的消息转发日志', () => {
const eventHandler = jest.fn();
service.on('message_forward', eventHandler);
service.logMessageForward({
messageId: 12345,
fromUserId: 'user1',
toUserIds: ['user2', 'user3'],
stream: 'test-stream',
topic: 'test-topic',
direction: 'downstream',
success: true,
latency: 50,
timestamp: new Date(),
});
expect(eventHandler).toHaveBeenCalledWith(
expect.objectContaining({
messageId: 12345,
})
);
service.removeListener('message_forward', eventHandler);
});
it('应该正确处理带有详情的操作确认', () => {
const eventHandler = jest.fn();
service.on('operation_confirmed', eventHandler);
service.confirmOperation({
operationId: 'op123',
operation: 'sendMessage',
userId: 'user1',
success: true,
timestamp: new Date(),
details: { messageId: 456, recipients: 3 },
});
expect(eventHandler).toHaveBeenCalledWith(
expect.objectContaining({
details: { messageId: 456, recipients: 3 },
})
);
service.removeListener('operation_confirmed', eventHandler);
});
it('应该正确处理带有元数据的告警', () => {
const eventHandler = jest.fn();
service.on('alert', eventHandler);
service.sendAlert({
id: 'alert123',
level: AlertLevel.WARNING,
title: 'Test Alert',
message: 'Test message',
component: 'test-component',
timestamp: new Date(),
metadata: { threshold: 5000, actual: 7000 },
});
expect(eventHandler).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { threshold: 5000, actual: 7000 },
})
);
service.removeListener('alert', eventHandler);
});
});
});

View File

@@ -0,0 +1,388 @@
/**
* Zulip用户管理服务测试
*
* 功能描述:
* - 测试UserManagementService的核心功能
* - 测试用户查询和验证逻辑
* - 测试错误处理和边界情况
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-06
*/
import { Test, TestingModule } from '@nestjs/testing';
import { UserManagementService, UserQueryRequest, UserValidationRequest } from './user_management.service';
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
// 模拟fetch
global.fetch = jest.fn();
describe('UserManagementService', () => {
let service: UserManagementService;
let mockConfigService: jest.Mocked<IZulipConfigService>;
let mockFetch: jest.MockedFunction<typeof fetch>;
beforeEach(async () => {
// 重置fetch模拟
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockClear();
// 创建模拟的配置服务
mockConfigService = {
getZulipConfig: jest.fn().mockReturnValue({
zulipServerUrl: 'https://test.zulip.com',
zulipBotEmail: 'bot@test.com',
zulipBotApiKey: 'test-api-key',
}),
getMapIdByStream: jest.fn(),
getStreamByMap: jest.fn(),
getMapConfig: jest.fn(),
hasMap: jest.fn(),
getAllMapIds: jest.fn(),
getMapConfigByStream: jest.fn(),
getAllStreams: jest.fn(),
hasStream: jest.fn(),
findObjectByTopic: jest.fn(),
getObjectsInMap: jest.fn(),
getTopicByObject: jest.fn(),
findNearbyObject: jest.fn(),
reloadConfig: jest.fn(),
validateConfig: jest.fn(),
getAllMapConfigs: jest.fn(),
getConfigStats: jest.fn(),
getConfigFilePath: jest.fn(),
configFileExists: jest.fn(),
enableConfigWatcher: jest.fn(),
disableConfigWatcher: jest.fn(),
isConfigWatcherEnabled: jest.fn(),
getFullConfiguration: jest.fn(),
updateConfigValue: jest.fn(),
exportMapConfig: jest.fn(),
} as jest.Mocked<IZulipConfigService>;
const module: TestingModule = await Test.createTestingModule({
providers: [
UserManagementService,
{
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigService,
},
],
}).compile();
service = module.get<UserManagementService>(UserManagementService);
});
it('应该正确初始化服务', () => {
expect(service).toBeDefined();
});
describe('checkUserExists - 检查用户是否存在', () => {
it('应该正确检查存在的用户', async () => {
// 模拟API响应
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [
{
user_id: 1,
email: 'test@example.com',
full_name: 'Test User',
is_active: true,
is_admin: false,
is_bot: false,
},
],
}),
} as Response);
const result = await service.checkUserExists('test@example.com');
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'https://test.zulip.com/api/v1/users',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'Authorization': expect.stringContaining('Basic'),
}),
})
);
});
it('应该正确检查不存在的用户', async () => {
// 模拟API响应
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [
{
user_id: 1,
email: 'other@example.com',
full_name: 'Other User',
is_active: true,
is_admin: false,
is_bot: false,
},
],
}),
} as Response);
const result = await service.checkUserExists('test@example.com');
expect(result).toBe(false);
});
it('应该处理无效邮箱', async () => {
const result = await service.checkUserExists('invalid-email');
expect(result).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
it('应该处理API调用失败', async () => {
// 模拟API失败
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response);
const result = await service.checkUserExists('test@example.com');
expect(result).toBe(false);
});
it('应该处理网络异常', async () => {
// 模拟网络异常
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const result = await service.checkUserExists('test@example.com');
expect(result).toBe(false);
});
});
describe('getUserInfo - 获取用户信息', () => {
it('应该成功获取用户信息', async () => {
const request: UserQueryRequest = {
email: 'test@example.com',
};
// 模拟API响应
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [
{
user_id: 1,
email: 'test@example.com',
full_name: 'Test User',
is_active: true,
is_admin: false,
is_bot: false,
},
],
}),
} as Response);
const result = await service.getUserInfo(request);
expect(result.success).toBe(true);
expect(result.userId).toBe(1);
expect(result.email).toBe('test@example.com');
expect(result.fullName).toBe('Test User');
expect(result.isActive).toBe(true);
expect(result.isAdmin).toBe(false);
expect(result.isBot).toBe(false);
});
it('应该处理用户不存在的情况', async () => {
const request: UserQueryRequest = {
email: 'nonexistent@example.com',
};
// 模拟API响应
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [],
}),
} as Response);
const result = await service.getUserInfo(request);
expect(result.success).toBe(false);
expect(result.error).toBe('用户不存在');
});
it('应该拒绝无效邮箱', async () => {
const request: UserQueryRequest = {
email: 'invalid-email',
};
const result = await service.getUserInfo(request);
expect(result.success).toBe(false);
expect(result.error).toBe('邮箱格式无效');
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('validateUserCredentials - 验证用户凭据', () => {
it('应该成功验证有效的API Key', async () => {
const request: UserValidationRequest = {
email: 'test@example.com',
apiKey: 'valid-api-key',
};
// 模拟API Key验证响应第一个调用
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
} as Response);
// 模拟用户列表API响应第二个调用
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [
{
user_id: 1,
email: 'test@example.com',
full_name: 'Test User',
is_active: true,
is_admin: false,
is_bot: false,
},
],
}),
} as Response);
const result = await service.validateUserCredentials(request);
expect(result.success).toBe(true);
expect(result.isValid).toBe(true);
expect(result.userId).toBe(1);
});
it('应该拒绝无效的API Key', async () => {
const request: UserValidationRequest = {
email: 'test@example.com',
apiKey: 'invalid-api-key',
};
// 模拟API Key验证失败第一个调用
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
} as Response);
const result = await service.validateUserCredentials(request);
expect(result.success).toBe(true);
expect(result.isValid).toBe(false);
});
it('应该拒绝空的API Key', async () => {
const request: UserValidationRequest = {
email: 'test@example.com',
apiKey: '',
};
const result = await service.validateUserCredentials(request);
expect(result.success).toBe(false);
expect(result.error).toBe('API Key不能为空');
expect(mockFetch).not.toHaveBeenCalled();
});
it('应该拒绝无效邮箱', async () => {
const request: UserValidationRequest = {
email: 'invalid-email',
apiKey: 'some-api-key',
};
const result = await service.validateUserCredentials(request);
expect(result.success).toBe(false);
expect(result.error).toBe('邮箱格式无效');
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('getAllUsers - 获取所有用户', () => {
it('应该成功获取用户列表', async () => {
// 模拟API响应
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [
{
user_id: 1,
email: 'user1@example.com',
full_name: 'User One',
is_active: true,
is_admin: false,
is_bot: false,
},
{
user_id: 2,
email: 'user2@example.com',
full_name: 'User Two',
is_active: true,
is_admin: true,
is_bot: false,
},
],
}),
} as Response);
const result = await service.getAllUsers();
expect(result.success).toBe(true);
expect(result.users).toHaveLength(2);
expect(result.totalCount).toBe(2);
expect(result.users?.[0]).toEqual({
userId: 1,
email: 'user1@example.com',
fullName: 'User One',
isActive: true,
isAdmin: false,
isBot: false,
});
});
it('应该处理空用户列表', async () => {
// 模拟API响应
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
members: [],
}),
} as Response);
const result = await service.getAllUsers();
expect(result.success).toBe(true);
expect(result.users).toHaveLength(0);
expect(result.totalCount).toBe(0);
});
it('应该处理API调用失败', async () => {
// 模拟API失败
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
} as Response);
const result = await service.getAllUsers();
expect(result.success).toBe(false);
expect(result.error).toContain('API调用失败');
});
});
});

View File

@@ -0,0 +1,539 @@
/**
* Zulip用户管理服务
*
* 功能描述:
* - 查询和验证Zulip用户信息
* - 检查用户是否存在
* - 获取用户详细信息
* - 验证用户凭据和权限
*
* 主要方法:
* - checkUserExists(): 检查用户是否存在
* - getUserInfo(): 获取用户详细信息
* - validateUserCredentials(): 验证用户凭据
* - getAllUsers(): 获取所有用户列表
*
* 使用场景:
* - 用户登录时验证用户存在性
* - 获取用户基本信息
* - 验证用户权限和状态
* - 管理员查看用户列表
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-06
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
/**
* Zulip API响应接口
*/
interface ZulipApiResponse {
result?: 'success' | 'error';
msg?: string;
message?: string;
}
/**
* 用户信息接口
*/
interface ZulipUser {
user_id: number;
email: string;
full_name: string;
is_active: boolean;
is_admin: boolean;
is_owner: boolean;
is_bot: boolean;
date_joined: string;
}
/**
* 用户列表响应接口
*/
interface ZulipUsersResponse extends ZulipApiResponse {
members?: ZulipUser[];
}
/**
* 用户查询请求接口
*/
export interface UserQueryRequest {
email: string;
}
/**
* 用户信息响应接口
*/
export interface UserInfoResponse {
success: boolean;
userId?: number;
email?: string;
fullName?: string;
isActive?: boolean;
isAdmin?: boolean;
isBot?: boolean;
dateJoined?: string;
error?: string;
}
/**
* 用户验证请求接口
*/
export interface UserValidationRequest {
email: string;
apiKey?: string;
}
/**
* 用户验证响应接口
*/
export interface UserValidationResponse {
success: boolean;
isValid?: boolean;
userId?: number;
error?: string;
}
/**
* 用户列表响应接口
*/
export interface UsersListResponse {
success: boolean;
users?: Array<{
userId: number;
email: string;
fullName: string;
isActive: boolean;
isAdmin: boolean;
isBot: boolean;
}>;
totalCount?: number;
error?: string;
}
/**
* Zulip用户管理服务类
*
* 职责:
* - 查询和验证Zulip用户信息
* - 检查用户是否存在于Zulip服务器
* - 获取用户详细信息和权限状态
* - 提供用户管理相关的API接口
*/
@Injectable()
export class UserManagementService {
private readonly logger = new Logger(UserManagementService.name);
constructor(
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configService: IZulipConfigService,
) {
this.logger.log('UserManagementService初始化完成');
}
/**
* 检查用户是否存在
*
* 功能描述:
* 通过Zulip API检查指定邮箱的用户是否存在
*
* 业务逻辑:
* 1. 获取所有用户列表
* 2. 在列表中查找指定邮箱
* 3. 返回用户存在性结果
*
* @param email 用户邮箱
* @returns Promise<boolean> 是否存在
*/
async checkUserExists(email: string): Promise<boolean> {
const startTime = Date.now();
this.logger.log('开始检查用户是否存在', {
operation: 'checkUserExists',
email,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证邮箱格式
if (!email || !this.isValidEmail(email)) {
this.logger.warn('邮箱格式无效', {
operation: 'checkUserExists',
email,
});
return false;
}
// 2. 获取用户列表
const usersResult = await this.getAllUsers();
if (!usersResult.success) {
this.logger.warn('获取用户列表失败', {
operation: 'checkUserExists',
email,
error: usersResult.error,
});
return false;
}
// 3. 检查用户是否存在
const userExists = usersResult.users?.some(user =>
user.email.toLowerCase() === email.toLowerCase()
) || false;
const duration = Date.now() - startTime;
this.logger.log('用户存在性检查完成', {
operation: 'checkUserExists',
email,
exists: userExists,
duration,
timestamp: new Date().toISOString(),
});
return userExists;
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('检查用户存在性失败', {
operation: 'checkUserExists',
email,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return false;
}
}
/**
* 获取用户详细信息
*
* 功能描述:
* 根据邮箱获取用户的详细信息
*
* @param request 用户查询请求
* @returns Promise<UserInfoResponse>
*/
async getUserInfo(request: UserQueryRequest): Promise<UserInfoResponse> {
const startTime = Date.now();
this.logger.log('开始获取用户信息', {
operation: 'getUserInfo',
email: request.email,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证请求参数
if (!request.email || !this.isValidEmail(request.email)) {
return {
success: false,
error: '邮箱格式无效',
};
}
// 2. 获取用户列表
const usersResult = await this.getAllUsers();
if (!usersResult.success) {
return {
success: false,
error: usersResult.error || '获取用户列表失败',
};
}
// 3. 查找指定用户
const user = usersResult.users?.find(u =>
u.email.toLowerCase() === request.email.toLowerCase()
);
if (!user) {
return {
success: false,
error: '用户不存在',
};
}
const duration = Date.now() - startTime;
this.logger.log('用户信息获取完成', {
operation: 'getUserInfo',
email: request.email,
userId: user.userId,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
userId: user.userId,
email: user.email,
fullName: user.fullName,
isActive: user.isActive,
isAdmin: user.isAdmin,
isBot: user.isBot,
};
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('获取用户信息失败', {
operation: 'getUserInfo',
email: request.email,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: '系统错误,请稍后重试',
};
}
}
/**
* 验证用户凭据
*
* 功能描述:
* 验证用户的API Key是否有效
*
* @param request 用户验证请求
* @returns Promise<UserValidationResponse>
*/
async validateUserCredentials(request: UserValidationRequest): Promise<UserValidationResponse> {
const startTime = Date.now();
this.logger.log('开始验证用户凭据', {
operation: 'validateUserCredentials',
email: request.email,
hasApiKey: !!request.apiKey,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证请求参数
if (!request.email || !this.isValidEmail(request.email)) {
return {
success: false,
error: '邮箱格式无效',
};
}
if (!request.apiKey) {
return {
success: false,
error: 'API Key不能为空',
};
}
// 2. 使用用户的API Key测试连接
const isValid = await this.testUserApiKey(request.email, request.apiKey);
// 3. 如果API Key有效获取用户ID
let userId = undefined;
if (isValid) {
const userInfo = await this.getUserInfo({ email: request.email });
if (userInfo.success) {
userId = userInfo.userId;
}
}
const duration = Date.now() - startTime;
this.logger.log('用户凭据验证完成', {
operation: 'validateUserCredentials',
email: request.email,
isValid,
userId,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
isValid,
userId,
};
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('验证用户凭据失败', {
operation: 'validateUserCredentials',
email: request.email,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: '系统错误,请稍后重试',
};
}
}
/**
* 获取所有用户列表
*
* 功能描述:
* 从Zulip服务器获取所有用户的列表
*
* @returns Promise<UsersListResponse>
*/
async getAllUsers(): Promise<UsersListResponse> {
this.logger.debug('开始获取用户列表', {
operation: 'getAllUsers',
timestamp: new Date().toISOString(),
});
try {
// 获取Zulip配置
const config = this.configService.getZulipConfig();
// 构建API URL
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
// 构建认证头
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
// 发送请求
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
this.logger.warn('获取用户列表失败', {
operation: 'getAllUsers',
status: response.status,
statusText: response.statusText,
});
return {
success: false,
error: `API调用失败: ${response.status} ${response.statusText}`,
};
}
const data: ZulipUsersResponse = await response.json();
// 转换数据格式
const users = data.members?.map(user => ({
userId: user.user_id,
email: user.email,
fullName: user.full_name,
isActive: user.is_active,
isAdmin: user.is_admin,
isBot: user.is_bot,
})) || [];
this.logger.debug('用户列表获取完成', {
operation: 'getAllUsers',
userCount: users.length,
timestamp: new Date().toISOString(),
});
return {
success: true,
users,
totalCount: users.length,
};
} catch (error) {
const err = error as Error;
this.logger.error('获取用户列表异常', {
operation: 'getAllUsers',
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: '系统错误,请稍后重试',
};
}
}
/**
* 测试用户API Key是否有效
*
* 功能描述:
* 使用用户的API Key测试是否能够成功调用Zulip API
*
* @param email 用户邮箱
* @param apiKey 用户API Key
* @returns Promise<boolean> 是否有效
* @private
*/
private async testUserApiKey(email: string, apiKey: string): Promise<boolean> {
this.logger.debug('测试用户API Key', {
operation: 'testUserApiKey',
email,
});
try {
// 获取Zulip配置
const config = this.configService.getZulipConfig();
// 构建API URL - 使用获取用户自己信息的接口
const apiUrl = `${config.zulipServerUrl}/api/v1/users/me`;
// 使用用户的API Key构建认证头
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
// 发送请求
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
const isValid = response.ok;
this.logger.debug('API Key测试完成', {
operation: 'testUserApiKey',
email,
isValid,
status: response.status,
});
return isValid;
} catch (error) {
const err = error as Error;
this.logger.error('测试API Key异常', {
operation: 'testUserApiKey',
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);
}
}

View File

@@ -0,0 +1,188 @@
/**
* Zulip用户注册服务测试
*
* 功能描述:
* - 测试UserRegistrationService的核心功能
* - 测试用户注册流程和验证逻辑
* - 测试错误处理和边界情况
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-06
*/
import { Test, TestingModule } from '@nestjs/testing';
import { UserRegistrationService, UserRegistrationRequest } from './user_registration.service';
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
describe('UserRegistrationService', () => {
let service: UserRegistrationService;
let mockConfigService: jest.Mocked<IZulipConfigService>;
beforeEach(async () => {
// 创建模拟的配置服务
mockConfigService = {
getZulipConfig: jest.fn().mockReturnValue({
zulipServerUrl: 'https://test.zulip.com',
zulipBotEmail: 'bot@test.com',
zulipBotApiKey: 'test-api-key',
}),
getMapIdByStream: jest.fn(),
getStreamByMap: jest.fn(),
getMapConfig: jest.fn(),
hasMap: jest.fn(),
getAllMapIds: jest.fn(),
getMapConfigByStream: jest.fn(),
getAllStreams: jest.fn(),
hasStream: jest.fn(),
findObjectByTopic: jest.fn(),
getObjectsInMap: jest.fn(),
getTopicByObject: jest.fn(),
findNearbyObject: jest.fn(),
reloadConfig: jest.fn(),
validateConfig: jest.fn(),
getAllMapConfigs: jest.fn(),
getConfigStats: jest.fn(),
getConfigFilePath: jest.fn(),
configFileExists: jest.fn(),
enableConfigWatcher: jest.fn(),
disableConfigWatcher: jest.fn(),
isConfigWatcherEnabled: jest.fn(),
getFullConfiguration: jest.fn(),
updateConfigValue: jest.fn(),
exportMapConfig: jest.fn(),
} as jest.Mocked<IZulipConfigService>;
const module: TestingModule = await Test.createTestingModule({
providers: [
UserRegistrationService,
{
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigService,
},
],
}).compile();
service = module.get<UserRegistrationService>(UserRegistrationService);
});
it('应该正确初始化服务', () => {
expect(service).toBeDefined();
});
describe('registerUser - 用户注册', () => {
it('应该成功注册有效用户', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: 'Test User',
password: 'password123',
};
const result = await service.registerUser(request);
expect(result.success).toBe(true);
expect(result.email).toBe(request.email);
expect(result.userId).toBeDefined();
expect(result.apiKey).toBeDefined();
expect(result.error).toBeUndefined();
});
it('应该拒绝无效邮箱', async () => {
const request: UserRegistrationRequest = {
email: 'invalid-email',
fullName: 'Test User',
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('邮箱格式无效');
});
it('应该拒绝空邮箱', async () => {
const request: UserRegistrationRequest = {
email: '',
fullName: 'Test User',
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('邮箱不能为空');
});
it('应该拒绝空用户名', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: '',
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('用户全名不能为空');
});
it('应该拒绝过短的用户名', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: 'A',
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('用户全名至少需要2个字符');
});
it('应该拒绝过长的用户名', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: 'A'.repeat(101), // 101个字符
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('用户全名不能超过100个字符');
});
it('应该拒绝过短的密码', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: 'Test User',
password: '123', // 只有3个字符
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('密码至少需要6个字符');
});
it('应该接受没有密码的注册', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: 'Test User',
// 不提供密码
};
const result = await service.registerUser(request);
expect(result.success).toBe(true);
});
it('应该拒绝过长的短名称', async () => {
const request: UserRegistrationRequest = {
email: 'test@example.com',
fullName: 'Test User',
shortName: 'A'.repeat(51), // 51个字符
};
const result = await service.registerUser(request);
expect(result.success).toBe(false);
expect(result.error).toContain('短名称不能超过50个字符');
});
});
});

View File

@@ -0,0 +1,531 @@
/**
* Zulip用户管理服务
*
* 功能描述:
* - 查询和验证Zulip用户信息
* - 检查用户是否存在
* - 获取用户详细信息
* - 管理用户API Key如果有权限
*
* 主要方法:
* - checkUserExists(): 检查用户是否存在
* - getUserInfo(): 获取用户详细信息
* - validateUserCredentials(): 验证用户凭据
* - getUserApiKey(): 获取用户API Key需要管理员权限
*
* 使用场景:
* - 用户登录时验证用户存在性
* - 获取用户基本信息
* - 验证用户权限和状态
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-06
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { IZulipConfigService } from '../interfaces/zulip-core.interfaces';
/**
* Zulip API响应接口
*/
interface ZulipApiResponse {
result?: 'success' | 'error';
msg?: string;
message?: string;
}
/**
* 用户列表响应接口
*/
interface ZulipUsersResponse extends ZulipApiResponse {
members?: Array<{
email: string;
user_id: number;
full_name: string;
}>;
}
/**
* 创建用户响应接口
*/
interface ZulipCreateUserResponse extends ZulipApiResponse {
user_id?: number;
}
/**
* API Key响应接口
*/
interface ZulipApiKeyResponse extends ZulipApiResponse {
api_key?: string;
}
export interface UserRegistrationRequest {
email: string;
fullName: string;
password?: string;
shortName?: string;
}
/**
* 用户注册响应接口
*/
export interface UserRegistrationResponse {
success: boolean;
userId?: number;
email?: string;
apiKey?: string;
error?: string;
details?: any;
}
/**
* Zulip用户注册服务类
*
* 职责:
* - 处理新用户在Zulip服务器上的注册
* - 验证用户信息的有效性
* - 与Zulip API交互创建用户账户
* - 管理注册流程和错误处理
*/
@Injectable()
export class UserRegistrationService {
private readonly logger = new Logger(UserRegistrationService.name);
constructor(
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configService: IZulipConfigService,
) {
this.logger.log('UserRegistrationService初始化完成');
}
/**
* 注册新用户到Zulip服务器
*
* 功能描述:
* 在Zulip服务器上创建新用户账户
*
* 业务逻辑:
* 1. 验证用户注册信息
* 2. 检查用户是否已存在
* 3. 调用Zulip API创建用户
* 4. 获取用户API Key
* 5. 返回注册结果
*
* @param request 用户注册请求数据
* @returns Promise<UserRegistrationResponse>
*/
async registerUser(request: UserRegistrationRequest): Promise<UserRegistrationResponse> {
const startTime = Date.now();
this.logger.log('开始注册Zulip用户', {
operation: 'registerUser',
email: request.email,
fullName: request.fullName,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证用户注册信息
const validationResult = this.validateUserInfo(request);
if (!validationResult.valid) {
this.logger.warn('用户注册信息验证失败', {
operation: 'registerUser',
email: request.email,
errors: validationResult.errors,
});
return {
success: false,
error: validationResult.errors.join(', '),
};
}
// TODO: 实现实际的Zulip用户注册逻辑
// 这里先返回模拟结果后续步骤中实现真实的API调用
// 2. 检查用户是否已存在
const userExists = await this.checkUserExists(request.email);
if (userExists) {
this.logger.warn('用户注册失败:用户已存在', {
operation: 'registerUser',
email: request.email,
});
return {
success: false,
error: '用户已存在',
};
}
// 3. 调用Zulip API创建用户
const createResult = await this.createZulipUser(request);
if (!createResult.success) {
return {
success: false,
error: createResult.error || '创建用户失败',
};
}
// 4. 获取用户API Key如果需要
let apiKey = undefined;
if (createResult.userId) {
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email);
if (apiKeyResult.success) {
apiKey = apiKeyResult.apiKey;
}
}
const duration = Date.now() - startTime;
this.logger.log('Zulip用户注册完成模拟', {
operation: 'registerUser',
email: request.email,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
userId: createResult.userId,
email: request.email,
apiKey: apiKey,
};
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('Zulip用户注册失败', {
operation: 'registerUser',
email: request.email,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: '注册失败,请稍后重试',
};
}
}
/**
* 验证用户注册信息
*
* 功能描述:
* 验证用户提供的注册信息是否有效
*
* @param request 用户注册请求
* @returns {valid: boolean, errors: string[]} 验证结果
* @private
*/
private validateUserInfo(request: UserRegistrationRequest): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
// 验证邮箱
if (!request.email || !request.email.trim()) {
errors.push('邮箱不能为空');
} else if (!this.isValidEmail(request.email)) {
errors.push('邮箱格式无效');
}
// 验证全名
if (!request.fullName || !request.fullName.trim()) {
errors.push('用户全名不能为空');
} else if (request.fullName.trim().length < 2) {
errors.push('用户全名至少需要2个字符');
} else if (request.fullName.trim().length > 100) {
errors.push('用户全名不能超过100个字符');
}
// 验证密码(如果提供)
if (request.password && request.password.length < 6) {
errors.push('密码至少需要6个字符');
}
// 验证短名称(如果提供)
if (request.shortName && request.shortName.trim().length > 50) {
errors.push('短名称不能超过50个字符');
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* 验证邮箱格式
*
* @param email 邮箱地址
* @returns boolean 是否有效
* @private
*/
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* 检查用户是否已存在
*
* 功能描述:
* 通过Zulip API检查指定邮箱的用户是否已存在
*
* @param email 用户邮箱
* @returns Promise<boolean> 是否存在
* @private
*/
private async checkUserExists(email: string): Promise<boolean> {
this.logger.debug('检查用户是否存在', {
operation: 'checkUserExists',
email,
});
try {
// 获取Zulip配置
const config = this.configService.getZulipConfig();
// 构建API URL
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
// 构建认证头
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
// 发送请求
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
this.logger.warn('获取用户列表失败', {
operation: 'checkUserExists',
status: response.status,
statusText: response.statusText,
});
return false; // 如果API调用失败假设用户不存在
}
const data: ZulipUsersResponse = await response.json();
// 检查用户是否在列表中
if (data.members && Array.isArray(data.members)) {
const userExists = data.members.some((user: any) =>
user.email && user.email.toLowerCase() === email.toLowerCase()
);
this.logger.debug('用户存在性检查完成', {
operation: 'checkUserExists',
email,
exists: userExists,
});
return userExists;
}
return false;
} catch (error) {
const err = error as Error;
this.logger.error('检查用户存在性失败', {
operation: 'checkUserExists',
email,
error: err.message,
});
// 如果检查失败,假设用户不存在,允许继续注册
return false;
}
}
/**
* 创建Zulip用户
*
* 功能描述:
* 通过Zulip API创建新用户账户
*
* @param request 用户注册请求
* @returns Promise<{success: boolean, userId?: number, error?: string}>
* @private
*/
private async createZulipUser(request: UserRegistrationRequest): Promise<{
success: boolean;
userId?: number;
error?: string;
}> {
this.logger.log('开始创建Zulip用户', {
operation: 'createZulipUser',
email: request.email,
fullName: request.fullName,
});
try {
// 获取Zulip配置
const config = this.configService.getZulipConfig();
// 构建API URL
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
// 构建认证头
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
// 构建请求体
const requestBody = new URLSearchParams();
requestBody.append('email', request.email);
requestBody.append('full_name', request.fullName);
if (request.password) {
requestBody.append('password', request.password);
}
if (request.shortName) {
requestBody.append('short_name', request.shortName);
}
// 发送请求
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: requestBody.toString(),
});
const data: ZulipCreateUserResponse = await response.json();
if (!response.ok) {
this.logger.warn('Zulip用户创建失败', {
operation: 'createZulipUser',
email: request.email,
status: response.status,
statusText: response.statusText,
error: data.msg || data.message,
});
return {
success: false,
error: data.msg || data.message || '创建用户失败',
};
}
this.logger.log('Zulip用户创建成功', {
operation: 'createZulipUser',
email: request.email,
userId: data.user_id,
});
return {
success: true,
userId: data.user_id,
};
} catch (error) {
const err = error as Error;
this.logger.error('创建Zulip用户异常', {
operation: 'createZulipUser',
email: request.email,
error: err.message,
}, err.stack);
return {
success: false,
error: '系统错误,请稍后重试',
};
}
}
/**
* 为用户生成API Key
*
* 功能描述:
* 为新创建的用户生成API Key用于后续的Zulip API调用
*
* @param userId 用户ID
* @param email 用户邮箱
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
* @private
*/
private async generateApiKey(userId: number, email: string): Promise<{
success: boolean;
apiKey?: string;
error?: string;
}> {
this.logger.log('开始生成用户API Key', {
operation: 'generateApiKey',
userId,
email,
});
try {
// 获取Zulip配置
const config = this.configService.getZulipConfig();
// 构建API URL
const apiUrl = `${config.zulipServerUrl}/api/v1/users/${userId}/api_key/regenerate`;
// 构建认证头
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
// 发送请求
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
const data: ZulipApiKeyResponse = await response.json();
if (!response.ok) {
this.logger.warn('生成API Key失败', {
operation: 'generateApiKey',
userId,
email,
status: response.status,
statusText: response.statusText,
error: data.msg || data.message,
});
return {
success: false,
error: data.msg || data.message || '生成API Key失败',
};
}
this.logger.log('API Key生成成功', {
operation: 'generateApiKey',
userId,
email,
});
return {
success: true,
apiKey: data.api_key,
};
} catch (error) {
const err = error as Error;
this.logger.error('生成API Key异常', {
operation: 'generateApiKey',
userId,
email,
error: err.message,
}, err.stack);
return {
success: false,
error: '系统错误,请稍后重试',
};
}
}
}

View File

@@ -40,10 +40,17 @@ async function bootstrap() {
logger: ['error', 'warn', 'log'],
});
// 允许前端后台如Vite/React跨域访问
// 允许前端后台如Vite/React跨域访问包括WebSocket
app.enableCors({
origin: true,
origin: [
'http://localhost:3000',
'http://localhost:5173', // Vite默认端口
'https://whaletownend.xinghangee.icu',
/^https:\/\/.*\.xinghangee\.icu$/
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
});
// 全局启用校验管道(核心配置)

131
test_zulip.js Normal file
View File

@@ -0,0 +1,131 @@
const io = require('socket.io-client');
// 使用用户 API Key 测试 Zulip 集成
async function testWithUserApiKey() {
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
console.log('📡 游戏服务器: https://whaletownend.xinghangee.icu/game');
const socket = io('wss://whaletownend.xinghangee.icu/game', {
transports: ['websocket', 'polling'], // WebSocket优先polling备用
timeout: 20000,
forceNew: true,
reconnection: true,
reconnectionAttempts: 3,
reconnectionDelay: 1000
});
let testStep = 0;
socket.on('connect', () => {
console.log('✅ WebSocket 连接成功');
testStep = 1;
// 使用包含用户 API Key 的 token
const loginMessage = {
type: 'login',
token: 'lCPWCPfGh7...fGF8_user_token'
};
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key');
socket.emit('login', loginMessage);
});
socket.on('login_success', (data) => {
console.log('✅ 步骤 1 完成: 登录成功');
console.log(' 会话ID:', data.sessionId);
console.log(' 用户ID:', data.userId);
console.log(' 用户名:', data.username);
console.log(' 当前地图:', data.currentMap);
testStep = 2;
// 等待 Zulip 客户端初始化
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
setTimeout(() => {
const chatMessage = {
t: 'chat',
content: '🎮 【用户API Key测试】来自游戏的消息\\n' +
'时间: ' + new Date().toLocaleString() + '\\n' +
'使用用户 API Key 发送此消息。',
scope: 'local'
};
console.log('📤 步骤 2: 发送消息到 Zulip使用用户 API Key');
console.log(' 目标 Stream: Whale Port');
socket.emit('chat', chatMessage);
}, 3000);
});
socket.on('chat_sent', (data) => {
console.log('✅ 步骤 2 完成: 消息发送成功');
console.log(' 响应:', JSON.stringify(data, null, 2));
// 只在第一次收到 chat_sent 时发送第二条消息
if (testStep === 2) {
testStep = 3;
setTimeout(() => {
// 先切换到 Pumpkin Valley 地图
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
const positionUpdate = {
t: 'position',
x: 150,
y: 400,
mapId: 'pumpkin_valley'
};
socket.emit('position_update', positionUpdate);
// 等待位置更新后发送消息
setTimeout(() => {
const chatMessage2 = {
t: 'chat',
content: '🎃 在南瓜谷发送的测试消息!',
scope: 'local'
};
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
socket.emit('chat', chatMessage2);
}, 1000);
}, 2000);
}
});
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 || '未知');
});
socket.on('error', (error) => {
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
});
socket.on('disconnect', () => {
console.log('🔌 WebSocket 连接已关闭');
console.log('');
console.log('📊 测试结果:');
console.log(' 完成步骤:', testStep, '/ 4');
if (testStep >= 3) {
console.log(' ✅ 核心功能正常!');
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
}
process.exit(0);
});
socket.on('connect_error', (error) => {
console.error('❌ 连接错误:', error.message);
process.exit(1);
});
// 20秒后自动关闭给足够时间完成测试
setTimeout(() => {
console.log('⏰ 测试时间到,关闭连接');
socket.disconnect();
}, 20000);
}
console.log('🔧 准备测试环境...');
testWithUserApiKey().catch(console.error);

196
test_zulip_registration.js Normal file
View File

@@ -0,0 +1,196 @@
/**
* Zulip用户注册真实环境测试脚本
*
* 功能描述:
* - 测试Zulip用户注册功能在真实环境下的表现
* - 验证API调用是否正常工作
* - 检查配置是否正确
*
* 使用方法:
* node test_zulip_registration.js
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-06
*/
const https = require('https');
const { URLSearchParams } = require('url');
// 配置信息
const config = {
zulipServerUrl: 'https://zulip.xinghangee.icu',
zulipBotEmail: 'angjustinl@mail.angforever.top',
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
};
/**
* 检查用户是否存在
*/
async function checkUserExists(email) {
console.log(`🔍 检查用户是否存在: ${email}`);
try {
const url = `${config.zulipServerUrl}/api/v1/users`;
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
return false;
}
const data = await response.json();
console.log(`📊 获取到 ${data.members?.length || 0} 个用户`);
if (data.members && Array.isArray(data.members)) {
const userExists = data.members.some(user =>
user.email && user.email.toLowerCase() === email.toLowerCase()
);
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
return userExists;
}
return false;
} catch (error) {
console.error(`❌ 检查用户存在性失败:`, error.message);
return false;
}
}
/**
* 创建测试用户
*/
async function createTestUser(email, fullName, password) {
console.log(`🚀 开始创建用户: ${email}`);
try {
const url = `${config.zulipServerUrl}/api/v1/users`;
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
const requestBody = new URLSearchParams();
requestBody.append('email', email);
requestBody.append('full_name', fullName);
if (password) {
requestBody.append('password', password);
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: requestBody.toString(),
});
const data = await response.json();
if (!response.ok) {
console.log(`❌ 用户创建失败: ${response.status} ${response.statusText}`);
console.log(`📝 错误信息: ${data.msg || data.message || '未知错误'}`);
return { success: false, error: data.msg || data.message };
}
console.log(`✅ 用户创建成功! 用户ID: ${data.user_id}`);
return { success: true, userId: data.user_id };
} catch (error) {
console.error(`❌ 创建用户异常:`, error.message);
return { success: false, error: error.message };
}
}
/**
* 测试连接
*/
async function testConnection() {
console.log('🔗 测试Zulip服务器连接...');
try {
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
console.log(`✅ 连接成功! 服务器版本: ${data.zulip_version || '未知'}`);
return true;
} else {
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
return false;
}
} catch (error) {
console.error(`❌ 连接异常:`, error.message);
return false;
}
}
/**
* 主测试函数
*/
async function main() {
console.log('🎯 开始Zulip用户注册测试');
console.log('=' * 50);
// 1. 测试连接
const connected = await testConnection();
if (!connected) {
console.log('❌ 无法连接到Zulip服务器测试终止');
return;
}
console.log('');
// 2. 生成测试用户信息
const timestamp = Date.now();
const testEmail = `test_user_${timestamp}@example.com`;
const testFullName = `Test User ${timestamp}`;
const testPassword = 'test123456';
console.log(`📋 测试用户信息:`);
console.log(` 邮箱: ${testEmail}`);
console.log(` 姓名: ${testFullName}`);
console.log(` 密码: ${testPassword}`);
console.log('');
// 3. 检查用户是否已存在
const userExists = await checkUserExists(testEmail);
if (userExists) {
console.log('⚠️ 用户已存在,跳过创建测试');
return;
}
console.log('');
// 4. 创建用户
const createResult = await createTestUser(testEmail, testFullName, testPassword);
console.log('');
console.log('📊 测试结果:');
if (createResult.success) {
console.log('✅ 用户注册功能正常工作');
console.log(` 新用户ID: ${createResult.userId}`);
} else {
console.log('❌ 用户注册功能存在问题');
console.log(` 错误信息: ${createResult.error}`);
}
console.log('');
console.log('🎉 测试完成');
}
// 运行测试
main().catch(error => {
console.error('💥 测试过程中发生未处理的错误:', error);
process.exit(1);
});

View File

@@ -0,0 +1,275 @@
/**
* Zulip用户管理真实环境测试脚本
*
* 功能描述:
* - 测试Zulip用户管理功能在真实环境下的表现
* - 验证用户查询、验证等API调用是否正常工作
* - 检查配置是否正确
*
* 使用方法:
* node test_zulip_user_management.js
*
* @author angjustinl
* @version 1.0.0
* @since 2025-01-06
*/
const https = require('https');
// 配置信息
const config = {
zulipServerUrl: 'https://zulip.xinghangee.icu',
zulipBotEmail: 'angjustinl@mail.angforever.top',
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
};
/**
* 获取所有用户列表
*/
async function getAllUsers() {
console.log('📋 获取所有用户列表...');
try {
const url = `${config.zulipServerUrl}/api/v1/users`;
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
return { success: false, error: `${response.status} ${response.statusText}` };
}
const data = await response.json();
const users = data.members?.map(user => ({
userId: user.user_id,
email: user.email,
fullName: user.full_name,
isActive: user.is_active,
isAdmin: user.is_admin,
isBot: user.is_bot,
})) || [];
console.log(`✅ 成功获取 ${users.length} 个用户`);
// 显示前几个用户信息
console.log('👥 用户列表预览:');
users.slice(0, 5).forEach((user, index) => {
console.log(` ${index + 1}. ${user.fullName} (${user.email})`);
console.log(` ID: ${user.userId}, 活跃: ${user.isActive}, 管理员: ${user.isAdmin}, 机器人: ${user.isBot}`);
});
if (users.length > 5) {
console.log(` ... 还有 ${users.length - 5} 个用户`);
}
return { success: true, users, totalCount: users.length };
} catch (error) {
console.error(`❌ 获取用户列表异常:`, error.message);
return { success: false, error: error.message };
}
}
/**
* 检查指定用户是否存在
*/
async function checkUserExists(email) {
console.log(`🔍 检查用户是否存在: ${email}`);
try {
const usersResult = await getAllUsers();
if (!usersResult.success) {
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
return false;
}
const userExists = usersResult.users.some(user =>
user.email.toLowerCase() === email.toLowerCase()
);
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
return userExists;
} catch (error) {
console.error(`❌ 检查用户存在性失败:`, error.message);
return false;
}
}
/**
* 获取用户详细信息
*/
async function getUserInfo(email) {
console.log(`📝 获取用户信息: ${email}`);
try {
const usersResult = await getAllUsers();
if (!usersResult.success) {
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
return { success: false, error: usersResult.error };
}
const user = usersResult.users.find(u =>
u.email.toLowerCase() === email.toLowerCase()
);
if (!user) {
console.log(`❌ 用户不存在: ${email}`);
return { success: false, error: '用户不存在' };
}
console.log(`✅ 用户信息获取成功:`);
console.log(` 用户ID: ${user.userId}`);
console.log(` 邮箱: ${user.email}`);
console.log(` 姓名: ${user.fullName}`);
console.log(` 状态: ${user.isActive ? '活跃' : '非活跃'}`);
console.log(` 权限: ${user.isAdmin ? '管理员' : '普通用户'}`);
console.log(` 类型: ${user.isBot ? '机器人' : '真实用户'}`);
return { success: true, user };
} catch (error) {
console.error(`❌ 获取用户信息失败:`, error.message);
return { success: false, error: error.message };
}
}
/**
* 测试用户API Key
*/
async function testUserApiKey(email, apiKey) {
console.log(`🔑 测试用户API Key: ${email}`);
try {
const url = `${config.zulipServerUrl}/api/v1/users/me`;
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
});
const isValid = response.ok;
if (isValid) {
const data = await response.json();
console.log(`✅ API Key有效! 用户信息:`);
console.log(` 用户ID: ${data.user_id}`);
console.log(` 邮箱: ${data.email}`);
console.log(` 姓名: ${data.full_name}`);
} else {
console.log(`❌ API Key无效: ${response.status} ${response.statusText}`);
}
return isValid;
} catch (error) {
console.error(`❌ 测试API Key异常:`, error.message);
return false;
}
}
/**
* 测试连接
*/
async function testConnection() {
console.log('🔗 测试Zulip服务器连接...');
try {
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
console.log(`✅ 连接成功! 服务器信息:`);
console.log(` 版本: ${data.zulip_version || '未知'}`);
console.log(` 服务器: ${data.realm_name || '未知'}`);
return true;
} else {
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
return false;
}
} catch (error) {
console.error(`❌ 连接异常:`, error.message);
return false;
}
}
/**
* 主测试函数
*/
async function main() {
console.log('🎯 开始Zulip用户管理测试');
console.log('='.repeat(50));
// 1. 测试连接
const connected = await testConnection();
if (!connected) {
console.log('❌ 无法连接到Zulip服务器测试终止');
return;
}
console.log('');
// 2. 获取所有用户列表
const usersResult = await getAllUsers();
if (!usersResult.success) {
console.log('❌ 无法获取用户列表,测试终止');
return;
}
console.log('');
// 3. 测试用户存在性检查
const testEmails = [
'angjustinl@mail.angforever.top', // 应该存在
'nonexistent@example.com', // 应该不存在
];
console.log('🔍 测试用户存在性检查:');
for (const email of testEmails) {
const exists = await checkUserExists(email);
console.log(` ${email}: ${exists ? '✅ 存在' : '❌ 不存在'}`);
}
console.log('');
// 4. 测试获取用户信息
console.log('📝 测试获取用户信息:');
const existingEmail = 'angjustinl@mail.angforever.top';
const userInfoResult = await getUserInfo(existingEmail);
console.log('');
// 5. 测试API Key验证如果有的话
console.log('🔑 测试API Key验证:');
const testApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; // 这是我们的测试API Key
const apiKeyValid = await testUserApiKey(existingEmail, testApiKey);
console.log('');
console.log('📊 测试结果总结:');
console.log(`✅ 服务器连接: 正常`);
console.log(`✅ 用户列表获取: 正常 (${usersResult.totalCount} 个用户)`);
console.log(`✅ 用户存在性检查: 正常`);
console.log(`✅ 用户信息获取: ${userInfoResult.success ? '正常' : '异常'}`);
console.log(`✅ API Key验证: ${apiKeyValid ? '正常' : '异常'}`);
console.log('');
console.log('🎉 用户管理功能测试完成');
}
// 运行测试
main().catch(error => {
console.error('💥 测试过程中发生未处理的错误:', error);
process.exit(1);
});