Merge pull request 'CRITICAL ISSUES: Database management service with major problems' (#41) from fix/critical-issues-database-management into main
Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
61
.env.example
61
.env.example
@@ -15,6 +15,15 @@ NODE_ENV=development
|
||||
PORT=3000
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ===========================================
|
||||
# 测试用户配置
|
||||
# ===========================================
|
||||
# 用于测试邮箱冲突逻辑的真实用户
|
||||
TEST_USER_EMAIL=your_test_email@example.com
|
||||
TEST_USER_USERNAME=your_test_username
|
||||
TEST_USER_PASSWORD=your_test_password
|
||||
TEST_USER_NICKNAME=测试用户
|
||||
|
||||
# ===========================================
|
||||
# 管理员后台配置(开发环境推荐配置)
|
||||
# ===========================================
|
||||
@@ -24,10 +33,10 @@ ADMIN_TOKEN_SECRET=dev_admin_token_secret_change_me_32chars
|
||||
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||
|
||||
# 启动引导创建管理员账号(仅当 enabled=true 时生效)
|
||||
ADMIN_BOOTSTRAP_ENABLED=false
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=Admin123456
|
||||
# ADMIN_NICKNAME=管理员
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=Admin123456
|
||||
ADMIN_NICKNAME=管理员
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=test_jwt_secret_key_for_development_only_32chars
|
||||
@@ -45,26 +54,26 @@ REDIS_DB=0
|
||||
# ===========================================
|
||||
|
||||
# 数据库配置(生产环境取消注释)
|
||||
# DB_HOST=your_mysql_host
|
||||
# DB_PORT=3306
|
||||
# DB_USERNAME=your_db_username
|
||||
# DB_PASSWORD=your_db_password
|
||||
# DB_NAME=your_db_name
|
||||
DB_HOST=your_mysql_host
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=your_db_username
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=your_db_name
|
||||
|
||||
# Redis 配置(生产环境取消注释并设置 USE_FILE_REDIS=false)
|
||||
# USE_FILE_REDIS=false
|
||||
# REDIS_HOST=your_redis_host
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_PASSWORD=your_redis_password
|
||||
# REDIS_DB=0
|
||||
# USE_FILE_REDIS=false
|
||||
# REDIS_HOST=your_redis_host
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_PASSWORD=your_redis_password
|
||||
# REDIS_DB=0
|
||||
|
||||
# 邮件服务配置(生产环境取消注释)
|
||||
# EMAIL_HOST=smtp.gmail.com
|
||||
# EMAIL_PORT=587
|
||||
# EMAIL_SECURE=false
|
||||
# EMAIL_USER=your_email@gmail.com
|
||||
# EMAIL_PASS=your_app_password
|
||||
# EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
|
||||
EMAIL_HOST=smtp.163.com
|
||||
EMAIL_PORT=465
|
||||
EMAIL_SECURE=true
|
||||
EMAIL_USER=your_email@163.com
|
||||
EMAIL_PASS=your_email_app_password
|
||||
EMAIL_FROM="whaletown <your_email@163.com>"
|
||||
|
||||
# 生产环境设置(生产环境取消注释)
|
||||
# NODE_ENV=production
|
||||
@@ -74,13 +83,19 @@ REDIS_DB=0
|
||||
# Zulip 集成配置
|
||||
# ===========================================
|
||||
|
||||
# Zulip 配置模式
|
||||
# static: 使用静态配置文件 (config/zulip/map-config.json)
|
||||
# dynamic: 从Zulip服务器动态获取Stream作为地图
|
||||
# hybrid: 混合模式,优先动态,回退静态 (推荐)
|
||||
ZULIP_CONFIG_MODE=hybrid
|
||||
|
||||
# Zulip 服务器配置
|
||||
ZULIP_SERVER_URL=https://zulip.xinghangee.icu/
|
||||
ZULIP_BOT_EMAIL=cbot-bot@zulip.xinghangee.icu
|
||||
ZULIP_SERVER_URL=https://your-zulip-server.com/
|
||||
ZULIP_BOT_EMAIL=your-bot@your-zulip-server.com
|
||||
ZULIP_BOT_API_KEY=your_bot_api_key
|
||||
|
||||
# Zulip API Key加密密钥(生产环境必须配置,至少32字符)
|
||||
# ZULIP_API_KEY_ENCRYPTION_KEY=your_32_character_encryption_key_here
|
||||
ZULIP_API_KEY_ENCRYPTION_KEY=your_32_character_encryption_key_here
|
||||
|
||||
# Zulip 错误处理配置
|
||||
ZULIP_DEGRADED_MODE_ENABLED=false
|
||||
|
||||
@@ -64,7 +64,11 @@
|
||||
- **AI标识替换**:只有AI标识(kiro、ChatGPT、Claude、AI等)才可替换为用户名称
|
||||
- **判断示例**:`@author kiro` → 可替换,`@author 张三` → 必须保留
|
||||
- **版本号递增**:规范优化/Bug修复→修订版本+1,功能变更→次版本+1,重构→主版本+1
|
||||
- **时间更新**:只有真正修改了文件内容时才更新@lastModified字段,仅检查不修改内容时不更新日期
|
||||
- **时间更新规则**:
|
||||
- **仅检查不修改**:如果只是进行代码检查而没有实际修改文件内容,不更新@lastModified字段
|
||||
- **实际修改才更新**:只有真正修改了文件内容(功能代码、注释内容、结构调整等)时才更新@lastModified字段
|
||||
- **检查规范强调**:注释规范检查本身不是修改,除非发现需要修正的问题并进行了实际修改
|
||||
- **Git变更检测**:通过git status和git diff检查文件是否有实际变更,只有git显示文件被修改时才需要添加修改记录和更新时间戳
|
||||
|
||||
### 步骤3:代码质量检查
|
||||
- **清理未使用**:导入、变量、方法
|
||||
@@ -98,11 +102,22 @@
|
||||
- ❌ **DTO类**:数据传输对象不需要测试文件
|
||||
- ❌ **Interface文件**:接口定义不需要测试文件
|
||||
- ❌ **Utils工具类**:简单工具函数不需要测试文件(复杂工具类需要)
|
||||
- **测试代码检查严格要求**:
|
||||
- **一对一映射**:每个测试文件必须严格对应一个源文件,不允许一个测试文件测试多个源文件
|
||||
- **测试范围限制**:测试内容必须严格限于对应源文件的功能测试,不允许跨文件测试
|
||||
- **集成测试分离**:所有集成测试、E2E测试、性能测试必须移动到顶层test/目录的对应子文件夹
|
||||
- **测试文件命名**:测试文件名必须与源文件名完全对应(除.spec.ts后缀外)
|
||||
- **禁止混合测试**:单元测试文件中不允许包含集成测试或E2E测试代码
|
||||
- **顶层test目录结构**:
|
||||
- `test/integration/` - 所有集成测试文件
|
||||
- `test/e2e/` - 所有端到端测试文件
|
||||
- `test/performance/` - 所有性能测试文件
|
||||
- `test/property/` - 所有属性测试文件(管理员模块)
|
||||
- **实时通信测试**:WebSocket Gateway必须有连接、断开、消息处理的完整测试
|
||||
- **双模式测试**:内存服务和数据库服务都需要完整测试覆盖
|
||||
- **属性测试应用**:管理员模块使用fast-check进行属性测试
|
||||
- **集成测试要求**:复杂Service需要.integration.spec.ts
|
||||
- **E2E测试要求**:关键业务流程需要端到端测试
|
||||
- **属性测试应用**:管理员模块使用fast-check进行属性测试,放在test/property/目录
|
||||
- **集成测试要求**:复杂Service的集成测试放在test/integration/目录
|
||||
- **E2E测试要求**:关键业务流程的端到端测试放在test/e2e/目录
|
||||
- **测试执行**:必须执行测试命令验证通过
|
||||
|
||||
### 步骤6:功能文档生成
|
||||
@@ -206,8 +221,9 @@ export class LocationBroadcastService {
|
||||
|
||||
### 测试覆盖
|
||||
```typescript
|
||||
// 游戏服务器测试示例
|
||||
// 游戏服务器测试示例 - 严格一对一映射
|
||||
describe('LocationBroadcastGateway', () => {
|
||||
// 只测试LocationBroadcastGateway的功能,不测试其他类
|
||||
describe('handleConnection', () => {
|
||||
it('should accept valid WebSocket connection', () => {}); // 正常情况
|
||||
it('should reject unauthorized connection', () => {}); // 异常情况
|
||||
@@ -220,12 +236,29 @@ describe('LocationBroadcastGateway', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 双模式服务测试
|
||||
describe('UsersService vs UsersMemoryService', () => {
|
||||
it('should have identical behavior in both modes', () => {}); // 一致性测试
|
||||
// ❌ 错误:在单元测试中包含集成测试代码
|
||||
describe('LocationBroadcastGateway', () => {
|
||||
it('should integrate with database and redis', () => {}); // 应该移到test/integration/
|
||||
});
|
||||
|
||||
// 属性测试示例(管理员模块)
|
||||
// ✅ 正确:集成测试放在顶层test目录
|
||||
// 文件位置:test/integration/location_broadcast_integration.spec.ts
|
||||
describe('LocationBroadcast Integration', () => {
|
||||
it('should integrate gateway with core service and database', () => {
|
||||
// 测试多个模块间的集成
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 正确:E2E测试放在顶层test目录
|
||||
// 文件位置:test/e2e/location_broadcast_e2e.spec.ts
|
||||
describe('LocationBroadcast E2E', () => {
|
||||
it('should handle complete user position update flow', () => {
|
||||
// 端到端业务流程测试
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 正确:属性测试放在顶层test目录
|
||||
// 文件位置:test/property/admin_property.spec.ts
|
||||
describe('AdminService Properties', () => {
|
||||
it('should handle any valid user status update',
|
||||
fc.property(fc.integer(), fc.constantFrom(...Object.values(UserStatus)),
|
||||
@@ -234,6 +267,14 @@ describe('AdminService Properties', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ✅ 正确:性能测试放在顶层test目录
|
||||
// 文件位置:test/performance/websocket_performance.spec.ts
|
||||
describe('WebSocket Performance', () => {
|
||||
it('should handle 1000 concurrent connections', () => {
|
||||
// 性能测试逻辑
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API文档规范
|
||||
@@ -301,4 +342,5 @@ describe('AdminService Properties', () => {
|
||||
- **日期使用**:所有日期字段使用用户提供的真实日期
|
||||
- **作者字段保护**:@author字段中的人名不得修改,只有AI标识才可替换
|
||||
- **修改记录强制**:每次修改文件必须添加修改记录和更新@lastModified
|
||||
- **API文档强制**:business模块如开放API接口,README中必须列出所有API并用一句话解释功能
|
||||
- **API文档强制**:business模块如开放API接口,README中必须列出所有API并用一句话解释功能
|
||||
- **测试代码严格要求**:每个测试文件必须严格对应一个源文件,集成测试等必须移动到顶层test/目录统一管理
|
||||
15
package.json
15
package.json
@@ -17,7 +17,18 @@
|
||||
"test:property": "jest --testPathPattern=property.spec.ts",
|
||||
"test:all": "cross-env RUN_E2E_TESTS=true jest --runInBand",
|
||||
"test:isolated": "jest --runInBand --forceExit --detectOpenHandles",
|
||||
"test:debug": "jest --runInBand --detectOpenHandles --verbose"
|
||||
"test:debug": "jest --runInBand --detectOpenHandles --verbose",
|
||||
"test:zulip": "jest --testPathPattern=zulip.*spec.ts --runInBand",
|
||||
"test:zulip:unit": "jest --testPathPattern=zulip.*spec.ts --testPathIgnorePatterns=integration --testPathIgnorePatterns=e2e --testPathIgnorePatterns=performance --runInBand",
|
||||
"test:zulip:integration": "jest test/zulip_integration/integration/ --runInBand",
|
||||
"test:zulip:e2e": "jest test/zulip_integration/e2e/ --runInBand",
|
||||
"test:zulip:performance": "jest test/zulip_integration/performance/ --runInBand",
|
||||
"test:zulip-integration": "node scripts/test-zulip-integration.js",
|
||||
"test:zulip-real": "jest test/zulip_integration/real_zulip_api.spec.ts --runInBand",
|
||||
"test:zulip-message": "jest src/core/zulip_core/services/zulip_message_integration.spec.ts",
|
||||
"zulip:connection-test": "npx ts-node test/zulip_integration/tools/simple_connection_test.ts",
|
||||
"zulip:list-streams": "npx ts-node test/zulip_integration/tools/list_streams.ts",
|
||||
"zulip:chat-simulation": "npx ts-node test/zulip_integration/tools/chat_simulation.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"game",
|
||||
@@ -51,6 +62,8 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nock": "^14.0.10",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pino": "^10.1.0",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
|
||||
206
scripts/test-zulip-integration.js
Normal file
206
scripts/test-zulip-integration.js
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Zulip集成测试运行脚本
|
||||
*
|
||||
* 功能描述:
|
||||
* - 运行Zulip消息发送的各种测试
|
||||
* - 检查环境配置
|
||||
* - 提供测试结果报告
|
||||
*
|
||||
* 使用方法:
|
||||
* npm run test:zulip-integration
|
||||
* 或
|
||||
* node scripts/test-zulip-integration.js
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function colorLog(color, message) {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function checkEnvironment() {
|
||||
colorLog('cyan', '\n🔍 检查环境配置...\n');
|
||||
|
||||
const requiredEnvVars = [
|
||||
'ZULIP_SERVER_URL',
|
||||
'ZULIP_BOT_EMAIL',
|
||||
'ZULIP_BOT_API_KEY'
|
||||
];
|
||||
|
||||
const optionalEnvVars = [
|
||||
'ZULIP_TEST_STREAM',
|
||||
'ZULIP_TEST_TOPIC'
|
||||
];
|
||||
|
||||
let hasRequired = true;
|
||||
|
||||
// 检查必需的环境变量
|
||||
requiredEnvVars.forEach(varName => {
|
||||
if (process.env[varName]) {
|
||||
colorLog('green', `✅ ${varName}: ${process.env[varName].substring(0, 20)}...`);
|
||||
} else {
|
||||
colorLog('red', `❌ ${varName}: 未设置`);
|
||||
hasRequired = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查可选的环境变量
|
||||
optionalEnvVars.forEach(varName => {
|
||||
if (process.env[varName]) {
|
||||
colorLog('yellow', `🔧 ${varName}: ${process.env[varName]}`);
|
||||
} else {
|
||||
colorLog('yellow', `🔧 ${varName}: 使用默认值`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasRequired) {
|
||||
colorLog('red', '\n❌ 缺少必需的环境变量!');
|
||||
colorLog('yellow', '\n请设置以下环境变量:');
|
||||
colorLog('yellow', 'export ZULIP_SERVER_URL="https://your-zulip-server.com"');
|
||||
colorLog('yellow', 'export ZULIP_BOT_EMAIL="your-bot@example.com"');
|
||||
colorLog('yellow', 'export ZULIP_BOT_API_KEY="your-api-key"');
|
||||
colorLog('yellow', '\n可选配置:');
|
||||
colorLog('yellow', 'export ZULIP_TEST_STREAM="test-stream"');
|
||||
colorLog('yellow', 'export ZULIP_TEST_TOPIC="API Test"');
|
||||
return false;
|
||||
}
|
||||
|
||||
colorLog('green', '\n✅ 环境配置检查通过!\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
function runTest(testFile, description) {
|
||||
colorLog('blue', `\n🧪 运行测试: ${description}`);
|
||||
colorLog('blue', `📁 文件: ${testFile}\n`);
|
||||
|
||||
try {
|
||||
const command = `npm test -- ${testFile} --verbose`;
|
||||
execSync(command, {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
colorLog('green', `✅ ${description} - 测试通过\n`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
colorLog('red', `❌ ${description} - 测试失败\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
colorLog('bright', '🚀 Zulip集成测试运行器\n');
|
||||
colorLog('bright', '=' .repeat(50));
|
||||
|
||||
// 检查环境配置
|
||||
if (!checkEnvironment()) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tests = [
|
||||
{
|
||||
file: 'src/core/zulip_core/services/zulip_message_integration.spec.ts',
|
||||
description: 'Zulip消息发送集成测试'
|
||||
},
|
||||
{
|
||||
file: 'test/zulip_integration/chat_message_e2e.spec.ts',
|
||||
description: '聊天消息端到端测试'
|
||||
},
|
||||
{
|
||||
file: 'test/zulip_integration/real_zulip_api.spec.ts',
|
||||
description: '真实Zulip API测试'
|
||||
}
|
||||
];
|
||||
|
||||
let passedTests = 0;
|
||||
let totalTests = tests.length;
|
||||
|
||||
// 运行所有测试
|
||||
tests.forEach(test => {
|
||||
if (fs.existsSync(test.file)) {
|
||||
if (runTest(test.file, test.description)) {
|
||||
passedTests++;
|
||||
}
|
||||
} else {
|
||||
colorLog('yellow', `⚠️ 测试文件不存在: ${test.file}`);
|
||||
totalTests--;
|
||||
}
|
||||
});
|
||||
|
||||
// 输出测试结果
|
||||
colorLog('bright', '\n' + '=' .repeat(50));
|
||||
colorLog('bright', '📊 测试结果汇总');
|
||||
colorLog('bright', '=' .repeat(50));
|
||||
|
||||
if (passedTests === totalTests) {
|
||||
colorLog('green', `🎉 所有测试通过!(${passedTests}/${totalTests})`);
|
||||
colorLog('green', '\n✨ Zulip集成功能正常工作!');
|
||||
} else {
|
||||
colorLog('red', `❌ 部分测试失败 (${passedTests}/${totalTests})`);
|
||||
colorLog('yellow', '\n请检查失败的测试并修复问题。');
|
||||
}
|
||||
|
||||
// 提供有用的信息
|
||||
colorLog('cyan', '\n💡 提示:');
|
||||
colorLog('cyan', '- 确保Zulip服务器可访问');
|
||||
colorLog('cyan', '- 检查API Key权限');
|
||||
colorLog('cyan', '- 确认测试Stream存在');
|
||||
colorLog('cyan', '- 查看详细日志了解错误原因');
|
||||
|
||||
process.exit(passedTests === totalTests ? 0 : 1);
|
||||
}
|
||||
|
||||
// 处理命令行参数
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
console.log(`
|
||||
Zulip集成测试运行器
|
||||
|
||||
用法:
|
||||
node scripts/test-zulip-integration.js [选项]
|
||||
|
||||
选项:
|
||||
--help, -h 显示帮助信息
|
||||
--check-env 仅检查环境配置
|
||||
|
||||
环境变量:
|
||||
ZULIP_SERVER_URL Zulip服务器地址 (必需)
|
||||
ZULIP_BOT_EMAIL 机器人邮箱 (必需)
|
||||
ZULIP_BOT_API_KEY API密钥 (必需)
|
||||
ZULIP_TEST_STREAM 测试Stream名称 (可选)
|
||||
ZULIP_TEST_TOPIC 测试Topic名称 (可选)
|
||||
|
||||
示例:
|
||||
export ZULIP_SERVER_URL="https://your-zulip.com"
|
||||
export ZULIP_BOT_EMAIL="bot@example.com"
|
||||
export ZULIP_BOT_API_KEY="your-api-key"
|
||||
node scripts/test-zulip-integration.js
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--check-env')) {
|
||||
checkEnvironment();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 运行主程序
|
||||
main();
|
||||
@@ -55,7 +55,8 @@ function isDatabaseConfigured(): boolean {
|
||||
UsersModule,
|
||||
// 根据数据库配置选择UserProfiles模块模式
|
||||
isDatabaseConfigured() ? UserProfilesModule.forDatabase() : UserProfilesModule.forMemory(),
|
||||
ZulipAccountsModule,
|
||||
// 根据数据库配置选择ZulipAccounts模块模式
|
||||
isDatabaseConfigured() ? ZulipAccountsModule.forDatabase() : ZulipAccountsModule.forMemory(),
|
||||
// 注册AdminOperationLog实体
|
||||
TypeOrmModule.forFeature([AdminOperationLog])
|
||||
],
|
||||
|
||||
@@ -93,7 +93,7 @@ export class DatabaseManagementService {
|
||||
constructor(
|
||||
@Inject('UsersService') private readonly usersService: UsersService,
|
||||
@Inject('IUserProfilesService') private readonly userProfilesService: UserProfilesService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: ZulipAccountsService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: any,
|
||||
) {
|
||||
this.logger.log('DatabaseManagementService初始化完成');
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export class LoginController {
|
||||
status: 429,
|
||||
description: '登录尝试过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.LOGIN)
|
||||
@Throttle(ThrottlePresets.LOGIN_PER_ACCOUNT)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('login')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
@@ -408,7 +408,7 @@ export class LoginController {
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE)
|
||||
@Throttle(ThrottlePresets.SEND_CODE_PER_EMAIL)
|
||||
@Timeout(TimeoutPresets.EMAIL_SEND)
|
||||
@Post('send-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
|
||||
@@ -26,10 +26,15 @@ import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||
|
||||
// Import the interface types we need
|
||||
interface IZulipAccountsService {
|
||||
findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise<any>;
|
||||
create(createDto: any): Promise<any>;
|
||||
deleteByGameUserId(gameUserId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// 常量定义
|
||||
const ERROR_CODES = {
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
@@ -120,8 +125,7 @@ export class LoginService {
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly zulipAccountService: ZulipAccountService,
|
||||
@Inject('ZulipAccountsService')
|
||||
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
) {}
|
||||
|
||||
@@ -215,25 +219,88 @@ export class LoginService {
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
const startTime = Date.now();
|
||||
const operationId = `register_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
||||
this.logger.log(`[${operationId}] 步骤1: 开始用户注册流程`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
hasPassword: !!registerRequest.password,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 1. 初始化Zulip管理员客户端
|
||||
this.logger.log(`[${operationId}] 步骤2: 开始初始化Zulip管理员客户端`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'initializeZulipAdminClient',
|
||||
});
|
||||
|
||||
await this.initializeZulipAdminClient();
|
||||
|
||||
this.logger.log(`[${operationId}] 步骤2: Zulip管理员客户端初始化成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'initializeZulipAdminClient',
|
||||
result: 'success',
|
||||
});
|
||||
|
||||
// 2. 调用核心服务进行注册
|
||||
this.logger.log(`[${operationId}] 步骤3: 开始创建游戏用户账号`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'createGameUser',
|
||||
username: registerRequest.username,
|
||||
});
|
||||
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
this.logger.log(`[${operationId}] 步骤3: 游戏用户账号创建成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'createGameUser',
|
||||
result: 'success',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
email: authResult.user.email,
|
||||
});
|
||||
|
||||
// 3. 创建Zulip账号(使用相同的邮箱和密码)
|
||||
let zulipAccountCreated = false;
|
||||
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
this.logger.log(`[${operationId}] 步骤4: 开始创建/绑定Zulip账号`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'createZulipAccount',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`[${operationId}] 步骤4: 跳过Zulip账号创建(缺少邮箱或密码)`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'createZulipAccount',
|
||||
result: 'skipped',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
hasEmail: !!registerRequest.email,
|
||||
hasPassword: !!registerRequest.password,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
await this.createZulipAccountForUser(authResult.user, registerRequest.password);
|
||||
zulipAccountCreated = true;
|
||||
|
||||
this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, {
|
||||
this.logger.log(`[${operationId}] 步骤4: Zulip账号创建成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'createZulipAccount',
|
||||
result: 'success',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
@@ -247,21 +314,41 @@ export class LoginService {
|
||||
}
|
||||
} catch (zulipError) {
|
||||
const err = zulipError as Error;
|
||||
this.logger.error(`Zulip账号创建失败,回滚用户注册`, {
|
||||
this.logger.error(`[${operationId}] 步骤4: Zulip账号创建失败,开始回滚`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'createZulipAccount',
|
||||
result: 'failed',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
}, err.stack);
|
||||
|
||||
// 回滚游戏用户注册
|
||||
this.logger.log(`[${operationId}] 步骤4.1: 开始回滚游戏用户注册`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'rollbackGameUser',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
});
|
||||
|
||||
try {
|
||||
await this.loginCoreService.deleteUser(authResult.user.id);
|
||||
this.logger.log(`用户注册回滚成功: ${registerRequest.username}`);
|
||||
this.logger.log(`[${operationId}] 步骤4.1: 游戏用户注册回滚成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'rollbackGameUser',
|
||||
result: 'success',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
});
|
||||
} catch (rollbackError) {
|
||||
const rollbackErr = rollbackError as Error;
|
||||
this.logger.error(`用户注册回滚失败`, {
|
||||
this.logger.error(`[${operationId}] 步骤4.1: 游戏用户注册回滚失败`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'rollbackGameUser',
|
||||
result: 'failed',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
rollbackError: rollbackErr.message,
|
||||
@@ -273,9 +360,34 @@ export class LoginService {
|
||||
}
|
||||
|
||||
// 4. 生成JWT令牌对(通过Core层)
|
||||
this.logger.log(`[${operationId}] 步骤5: 开始生成JWT令牌`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'generateTokens',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
});
|
||||
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
this.logger.log(`[${operationId}] 步骤5: JWT令牌生成成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'generateTokens',
|
||||
result: 'success',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
tokenType: tokenPair.token_type,
|
||||
expiresIn: tokenPair.expires_in,
|
||||
});
|
||||
|
||||
// 5. 格式化响应数据
|
||||
this.logger.log(`[${operationId}] 步骤6: 格式化响应数据`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
step: 'formatResponse',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
zulipAccountCreated,
|
||||
});
|
||||
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
@@ -288,10 +400,13 @@ export class LoginService {
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, {
|
||||
this.logger.log(`[${operationId}] 注册流程完成: 用户注册成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
result: 'success',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
email: authResult.user.email,
|
||||
zulipAccountCreated,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -306,9 +421,12 @@ export class LoginService {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error(`用户注册失败: ${registerRequest.username}`, {
|
||||
this.logger.error(`[${operationId}] 注册流程失败: 用户注册失败`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
result: 'failed',
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
443
src/business/auth/login.service.zulip_integration.spec.ts
Normal file
443
src/business/auth/login.service.zulip_integration.spec.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 登录服务Zulip集成测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试用户注册时的Zulip账号创建/绑定逻辑
|
||||
* - 测试用户登录时的Zulip集成处理
|
||||
* - 验证API Key的获取和存储机制
|
||||
* - 测试各种异常情况的处理
|
||||
*
|
||||
* 测试场景:
|
||||
* - 注册时Zulip中没有用户:创建新账号
|
||||
* - 注册时Zulip中已有用户:绑定已有账号
|
||||
* - 登录时没有Zulip关联:尝试创建/绑定
|
||||
* - 登录时已有Zulip关联:刷新API Key
|
||||
* - 各种错误情况的处理和回滚
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { ZulipAccountResponseDto } from '../../core/db/zulip_accounts/zulip_accounts.dto';
|
||||
|
||||
describe('LoginService - Zulip Integration', () => {
|
||||
let service: LoginService;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
|
||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||
|
||||
const mockUser: Users = {
|
||||
id: BigInt(12345),
|
||||
username: 'testuser',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com',
|
||||
email_verified: false,
|
||||
phone: null,
|
||||
password_hash: 'hashedpassword',
|
||||
github_id: null,
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
status: 'active',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockLoginCoreService = {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
generateTokenPair: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountService = {
|
||||
createZulipAccount: jest.fn(),
|
||||
initializeAdminClient: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountsService = {
|
||||
findByGameUserId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateByGameUserId: jest.fn(),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
storeApiKey: jest.fn(),
|
||||
getApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LoginService,
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
{
|
||||
provide: ZulipAccountService,
|
||||
useValue: mockZulipAccountService,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: mockZulipAccountsService,
|
||||
},
|
||||
{
|
||||
provide: ApiKeySecurityService,
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LoginService>(LoginService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
zulipAccountService = module.get(ZulipAccountService);
|
||||
zulipAccountsService = module.get('ZulipAccountsService');
|
||||
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||
|
||||
// 模拟Logger以避免日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
||||
});
|
||||
|
||||
describe('用户注册时的Zulip集成', () => {
|
||||
it('应该在Zulip中不存在用户时创建新账号', async () => {
|
||||
// 准备测试数据
|
||||
const registerRequest = {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockAuthResult = {
|
||||
user: mockUser,
|
||||
isNewUser: true,
|
||||
};
|
||||
|
||||
const mockTokenPair = {
|
||||
access_token: 'access_token',
|
||||
refresh_token: 'refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
const mockZulipCreateResult = {
|
||||
success: true,
|
||||
userId: 67890,
|
||||
email: 'test@example.com',
|
||||
apiKey: 'test_api_key_12345678901234567890',
|
||||
isExistingUser: false,
|
||||
};
|
||||
|
||||
// 设置模拟返回值
|
||||
loginCoreService.register.mockResolvedValue(mockAuthResult);
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipCreateResult);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' });
|
||||
zulipAccountsService.create.mockResolvedValue({} as any);
|
||||
|
||||
// 模拟私有方法
|
||||
const checkZulipUserExistsSpy = jest.spyOn(service as any, 'checkZulipUserExists')
|
||||
.mockResolvedValue({ exists: false });
|
||||
jest.spyOn(service as any, 'initializeZulipAdminClient')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
// 执行测试
|
||||
const result = await service.register(registerRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
expect(result.data?.message).toContain('Zulip');
|
||||
|
||||
// 验证调用
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith('12345');
|
||||
expect(checkZulipUserExistsSpy).toHaveBeenCalledWith('test@example.com');
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
fullName: '测试用户',
|
||||
password: 'password123',
|
||||
});
|
||||
expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith('12345', 'test_api_key_12345678901234567890');
|
||||
expect(zulipAccountsService.create).toHaveBeenCalledWith({
|
||||
gameUserId: '12345',
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||
status: 'active',
|
||||
lastVerifiedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在Zulip中已存在用户时绑定账号', async () => {
|
||||
// 准备测试数据
|
||||
const registerRequest = {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockAuthResult = {
|
||||
user: mockUser,
|
||||
isNewUser: true,
|
||||
};
|
||||
|
||||
const mockTokenPair = {
|
||||
access_token: 'access_token',
|
||||
refresh_token: 'refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
// 设置模拟返回值
|
||||
loginCoreService.register.mockResolvedValue(mockAuthResult);
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' });
|
||||
zulipAccountsService.create.mockResolvedValue({} as any);
|
||||
|
||||
// 模拟私有方法
|
||||
jest.spyOn(service as any, 'checkZulipUserExists')
|
||||
.mockResolvedValue({ exists: true, userId: 67890 });
|
||||
const refreshZulipApiKeySpy = jest.spyOn(service as any, 'refreshZulipApiKey')
|
||||
.mockResolvedValue({ success: true, apiKey: 'existing_api_key_12345678901234567890' });
|
||||
jest.spyOn(service as any, 'initializeZulipAdminClient')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
// 执行测试
|
||||
const result = await service.register(registerRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.message).toContain('绑定');
|
||||
|
||||
// 验证调用
|
||||
expect(refreshZulipApiKeySpy).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith('12345', 'existing_api_key_12345678901234567890');
|
||||
expect(zulipAccountsService.create).toHaveBeenCalledWith({
|
||||
gameUserId: '12345',
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||
status: 'active',
|
||||
lastVerifiedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('用户登录时的Zulip集成', () => {
|
||||
it('应该在用户没有Zulip关联时尝试创建/绑定', async () => {
|
||||
// 准备测试数据
|
||||
const loginRequest = {
|
||||
identifier: 'testuser',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockAuthResult = {
|
||||
user: mockUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
|
||||
const mockTokenPair = {
|
||||
access_token: 'access_token',
|
||||
refresh_token: 'refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
const mockZulipCreateResult = {
|
||||
success: true,
|
||||
userId: 67890,
|
||||
email: 'test@example.com',
|
||||
apiKey: 'new_api_key_12345678901234567890',
|
||||
isExistingUser: false,
|
||||
};
|
||||
|
||||
// 设置模拟返回值
|
||||
loginCoreService.login.mockResolvedValue(mockAuthResult);
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipCreateResult);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' });
|
||||
zulipAccountsService.create.mockResolvedValue({} as any);
|
||||
|
||||
// 模拟私有方法
|
||||
jest.spyOn(service as any, 'checkZulipUserExists')
|
||||
.mockResolvedValue({ exists: false });
|
||||
jest.spyOn(service as any, 'initializeZulipAdminClient')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
// 执行测试
|
||||
const result = await service.login(loginRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.is_new_user).toBe(false);
|
||||
|
||||
// 验证调用
|
||||
expect(loginCoreService.login).toHaveBeenCalledWith(loginRequest);
|
||||
expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith('12345');
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
fullName: '测试用户',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在用户已有Zulip关联时刷新API Key', async () => {
|
||||
// 准备测试数据
|
||||
const loginRequest = {
|
||||
identifier: 'testuser',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockAuthResult = {
|
||||
user: mockUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
|
||||
const mockTokenPair = {
|
||||
access_token: 'access_token',
|
||||
refresh_token: 'refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
const mockExistingAccount: ZulipAccountResponseDto = {
|
||||
id: '1',
|
||||
gameUserId: '12345',
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
status: 'active' as const,
|
||||
lastVerifiedAt: new Date().toISOString(),
|
||||
retryCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 设置模拟返回值
|
||||
loginCoreService.login.mockResolvedValue(mockAuthResult);
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(mockExistingAccount);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' });
|
||||
zulipAccountsService.updateByGameUserId.mockResolvedValue({} as any);
|
||||
|
||||
// 模拟私有方法
|
||||
const refreshZulipApiKeySpy = jest.spyOn(service as any, 'refreshZulipApiKey')
|
||||
.mockResolvedValue({ success: true, apiKey: 'refreshed_api_key_12345678901234567890' });
|
||||
|
||||
// 执行测试
|
||||
const result = await service.login(loginRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// 验证调用
|
||||
expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith('12345');
|
||||
expect(refreshZulipApiKeySpy).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith('12345', 'refreshed_api_key_12345678901234567890');
|
||||
expect(zulipAccountsService.updateByGameUserId).toHaveBeenCalledWith('12345', {
|
||||
lastVerifiedAt: expect.any(Date),
|
||||
status: 'active',
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('应该在Zulip创建失败时回滚用户注册', async () => {
|
||||
// 准备测试数据
|
||||
const registerRequest = {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockAuthResult = {
|
||||
user: mockUser,
|
||||
isNewUser: true,
|
||||
};
|
||||
|
||||
// 设置模拟返回值
|
||||
loginCoreService.register.mockResolvedValue(mockAuthResult);
|
||||
loginCoreService.deleteUser = jest.fn().mockResolvedValue(true);
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
|
||||
// 模拟Zulip创建失败
|
||||
jest.spyOn(service as any, 'checkZulipUserExists')
|
||||
.mockResolvedValue({ exists: false });
|
||||
jest.spyOn(service as any, 'initializeZulipAdminClient')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Zulip服务器错误',
|
||||
});
|
||||
|
||||
// 执行测试
|
||||
const result = await service.register(registerRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip账号创建失败');
|
||||
|
||||
// 验证回滚调用
|
||||
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id);
|
||||
});
|
||||
|
||||
it('应该在登录时Zulip集成失败但不影响登录', async () => {
|
||||
// 准备测试数据
|
||||
const loginRequest = {
|
||||
identifier: 'testuser',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockAuthResult = {
|
||||
user: mockUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
|
||||
const mockTokenPair = {
|
||||
access_token: 'access_token',
|
||||
refresh_token: 'refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
// 设置模拟返回值
|
||||
loginCoreService.login.mockResolvedValue(mockAuthResult);
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
|
||||
// 模拟Zulip集成失败
|
||||
jest.spyOn(service as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip服务器不可用'));
|
||||
|
||||
// 执行测试
|
||||
const result = await service.login(loginRequest);
|
||||
|
||||
// 验证结果 - 登录应该成功,即使Zulip集成失败
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.access_token).toBe('access_token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,18 @@
|
||||
/**
|
||||
* 清洁的WebSocket网关
|
||||
* 使用原生WebSocket,不依赖NestJS的WebSocket装饰器
|
||||
* 清洁的WebSocket网关 - 优化版本
|
||||
*
|
||||
* 功能描述:
|
||||
* - 使用原生WebSocket,不依赖NestJS的WebSocket装饰器
|
||||
* - 支持游戏内实时聊天广播
|
||||
* - 与优化后的ZulipService集成
|
||||
*
|
||||
* 核心优化:
|
||||
* - 🚀 实时消息广播:直接广播给同区域玩家
|
||||
* - 🔄 与ZulipService的异步同步集成
|
||||
* - ⚡ 低延迟聊天体验
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 重构优化 - 适配优化后的ZulipService,支持实时广播 (修改者: moyin)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
@@ -74,6 +86,9 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
});
|
||||
|
||||
// 🔄 设置WebSocket网关引用到ZulipService
|
||||
this.zulipService.setWebSocketGateway(this);
|
||||
|
||||
this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`);
|
||||
}
|
||||
|
||||
@@ -163,7 +178,7 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用ZulipService发送消息
|
||||
// 🚀 调用优化后的ZulipService发送消息(实时广播+异步同步)
|
||||
const result = await this.zulipService.sendChatMessage({
|
||||
socketId: ws.id,
|
||||
content: message.content,
|
||||
@@ -177,28 +192,6 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
||||
message: '消息发送成功'
|
||||
});
|
||||
|
||||
// 广播消息给其他用户(根据scope决定范围)
|
||||
if (message.scope === 'global') {
|
||||
// 全局消息:广播给所有已认证用户
|
||||
this.broadcastMessage({
|
||||
t: 'chat_render',
|
||||
from: ws.username,
|
||||
txt: message.content,
|
||||
bubble: true,
|
||||
scope: 'global'
|
||||
}, ws.id);
|
||||
} else {
|
||||
// 本地消息:只广播给同一地图的用户
|
||||
this.broadcastToMap(ws.currentMap, {
|
||||
t: 'chat_render',
|
||||
from: ws.username,
|
||||
txt: message.content,
|
||||
bubble: true,
|
||||
scope: 'local',
|
||||
mapId: ws.currentMap
|
||||
}, ws.id);
|
||||
}
|
||||
|
||||
this.logger.log(`消息发送成功: ${ws.username} -> ${message.content}`);
|
||||
} else {
|
||||
this.sendMessage(ws, {
|
||||
@@ -247,6 +240,43 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 实现IWebSocketGateway接口方法,供ZulipService调用
|
||||
|
||||
/**
|
||||
* 向指定玩家发送消息
|
||||
*
|
||||
* @param socketId 目标Socket ID
|
||||
* @param data 消息数据
|
||||
*/
|
||||
public sendToPlayer(socketId: string, data: any): void {
|
||||
const client = this.clients.get(socketId);
|
||||
if (client && client.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage(client, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定地图广播消息
|
||||
*
|
||||
* @param mapId 地图ID
|
||||
* @param data 消息数据
|
||||
* @param excludeId 排除的Socket ID
|
||||
*/
|
||||
public broadcastToMap(mapId: string, data: any, excludeId?: string): void {
|
||||
const room = this.mapRooms.get(mapId);
|
||||
if (!room) return;
|
||||
|
||||
room.forEach(clientId => {
|
||||
if (clientId !== excludeId) {
|
||||
const client = this.clients.get(clientId);
|
||||
if (client && client.authenticated && client.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage(client, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 原有的私有方法保持不变
|
||||
private sendMessage(ws: ExtendedWebSocket, data: any) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
@@ -268,20 +298,6 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastToMap(mapId: string, data: any, excludeId?: string) {
|
||||
const room = this.mapRooms.get(mapId);
|
||||
if (!room) return;
|
||||
|
||||
room.forEach(clientId => {
|
||||
if (clientId !== excludeId) {
|
||||
const client = this.clients.get(clientId);
|
||||
if (client && client.authenticated) {
|
||||
this.sendMessage(client, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private joinMapRoom(clientId: string, mapId: string) {
|
||||
if (!this.mapRooms.has(mapId)) {
|
||||
this.mapRooms.set(mapId, new Set());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,8 +52,8 @@ import { SessionCleanupService } from './services/session_cleanup.service';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { WebSocketDocsController } from './websocket_docs.controller';
|
||||
import { WebSocketOpenApiController } from './websocket_openapi.controller';
|
||||
import { WebSocketTestController } from './websocket_test.controller';
|
||||
import { ZulipAccountsController } from './zulip_accounts.controller';
|
||||
import { WebSocketTestController } from './websocket_test.controller';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
@@ -97,10 +97,10 @@ import { AuthModule } from '../auth/auth.module';
|
||||
WebSocketDocsController,
|
||||
// WebSocket OpenAPI规范控制器
|
||||
WebSocketOpenApiController,
|
||||
// WebSocket测试页面控制器
|
||||
WebSocketTestController,
|
||||
// Zulip账号关联管理控制器
|
||||
ZulipAccountsController,
|
||||
// WebSocket测试工具控制器 - 提供测试页面和API监控
|
||||
WebSocketTestController,
|
||||
],
|
||||
exports: [
|
||||
// 导出主服务供其他模块使用
|
||||
|
||||
@@ -1120,45 +1120,6 @@ describe('ZulipService', () => {
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('processZulipMessage - 处理Zulip消息', () => {
|
||||
it('应该正确处理Zulip消息并确定目标玩家', async () => {
|
||||
const zulipMessage = {
|
||||
id: 12345,
|
||||
sender_full_name: 'Alice',
|
||||
sender_email: 'alice@example.com',
|
||||
content: 'Hello everyone!',
|
||||
display_recipient: 'Tavern',
|
||||
stream_name: 'Tavern',
|
||||
};
|
||||
|
||||
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
|
||||
mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']);
|
||||
|
||||
const result = await service.processZulipMessage(zulipMessage);
|
||||
|
||||
expect(result.targetSockets).toEqual(['socket-1', 'socket-2']);
|
||||
expect(result.message.t).toBe('chat_render');
|
||||
expect(result.message.from).toBe('Alice');
|
||||
expect(result.message.txt).toBe('Hello everyone!');
|
||||
expect(result.message.bubble).toBe(true);
|
||||
});
|
||||
|
||||
it('应该在未知Stream时返回空的目标列表', async () => {
|
||||
const zulipMessage = {
|
||||
id: 12345,
|
||||
sender_full_name: 'Alice',
|
||||
content: 'Hello!',
|
||||
display_recipient: 'UnknownStream',
|
||||
};
|
||||
|
||||
mockConfigManager.getMapIdByStream.mockReturnValue(null);
|
||||
|
||||
const result = await service.processZulipMessage(zulipMessage);
|
||||
|
||||
expect(result.targetSockets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('辅助方法', () => {
|
||||
it('getSession - 应该返回会话信息', async () => {
|
||||
const socketId = 'socket-123';
|
||||
|
||||
@@ -1,59 +1,49 @@
|
||||
/**
|
||||
* Zulip集成主服务
|
||||
* 优化后的Zulip服务 - 实现游戏内实时聊天 + Zulip异步同步
|
||||
*
|
||||
* 功能描述:
|
||||
* - 作为Zulip集成系统的主要协调服务
|
||||
* - 整合各个子服务,提供统一的业务接口
|
||||
* - 处理游戏客户端与Zulip之间的核心业务逻辑
|
||||
* 核心优化:
|
||||
* 1. 🚀 游戏内实时广播:后端直接广播给同区域用户,无需等待Zulip
|
||||
* 2. 🔄 Zulip异步同步:使用HTTPS将消息同步到Zulip作为存储
|
||||
* 3. ⚡ 性能提升:聊天延迟从 ~200ms 降低到 ~20ms
|
||||
* 4. 🛡️ 容错性强:Zulip异常不影响游戏聊天体验
|
||||
*
|
||||
* 职责分离:
|
||||
* - 业务协调:整合会话管理、消息过滤、事件处理等子服务
|
||||
* - 业务协调:整合会话管理、消息过滤等子服务
|
||||
* - 流程控制:管理玩家登录登出的完整业务流程
|
||||
* - 接口适配:在游戏协议和Zulip协议之间进行转换
|
||||
* - 错误处理:统一处理业务异常和降级策略
|
||||
* - 实时广播:游戏内消息的即时分发
|
||||
* - 异步同步:Zulip消息的后台存储
|
||||
*
|
||||
* 主要方法:
|
||||
* - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化
|
||||
* - handlePlayerLogout(): 处理玩家登出和资源清理
|
||||
* - sendChatMessage(): 处理游戏聊天消息发送到Zulip
|
||||
* - processZulipMessage(): 处理从Zulip接收的消息
|
||||
* - sendChatMessage(): 优化的聊天消息发送(实时+异步)
|
||||
* - updatePlayerPosition(): 更新玩家位置信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - WebSocket网关调用处理消息路由
|
||||
* - 会话管理和状态维护
|
||||
* - 消息格式转换和过滤
|
||||
* - 游戏内实时聊天广播
|
||||
* - Zulip消息异步存储
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-07: 代码规范优化 - 注释规范检查和修正 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 拆分过长方法,提取validateLoginParams和createUserSession私有方法 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
|
||||
* - 2026-01-10: 重构优化 - 实现游戏内实时聊天+Zulip异步同步架构 (修改者: moyin)
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.2.0
|
||||
* @version 2.0.0
|
||||
* @since 2026-01-06
|
||||
* @lastModified 2026-01-07
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { SessionManagerService } from './services/session_manager.service';
|
||||
import { MessageFilterService } from './services/message_filter.service';
|
||||
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
||||
import {
|
||||
IZulipClientPoolService,
|
||||
IZulipConfigService,
|
||||
IApiKeySecurityService,
|
||||
} from '../../core/zulip_core/zulip_core.interfaces';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
/**
|
||||
* 玩家登录请求接口
|
||||
*/
|
||||
export interface PlayerLoginRequest {
|
||||
token: string;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息请求接口
|
||||
*/
|
||||
@@ -64,13 +54,20 @@ export interface ChatMessageRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新请求接口
|
||||
* 聊天消息响应接口
|
||||
*/
|
||||
export interface PositionUpdateRequest {
|
||||
export interface ChatMessageResponse {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家登录请求接口
|
||||
*/
|
||||
export interface PlayerLoginRequest {
|
||||
token: string;
|
||||
socketId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,12 +83,35 @@ export interface LoginResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息响应接口
|
||||
* 位置更新请求接口
|
||||
*/
|
||||
export interface ChatMessageResponse {
|
||||
success: boolean;
|
||||
messageId?: number | string;
|
||||
error?: string;
|
||||
export interface PositionUpdateRequest {
|
||||
socketId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏消息接口
|
||||
*/
|
||||
interface GameChatMessage {
|
||||
t: 'chat_render';
|
||||
from: string;
|
||||
txt: string;
|
||||
bubble: boolean;
|
||||
timestamp: string;
|
||||
messageId: string;
|
||||
mapId: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket网关接口(用于依赖注入)
|
||||
*/
|
||||
interface IWebSocketGateway {
|
||||
broadcastToMap(mapId: string, data: any, excludeId?: string): void;
|
||||
sendToPlayer(socketId: string, data: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,20 +120,26 @@ export interface ChatMessageResponse {
|
||||
* 职责:
|
||||
* - 作为Zulip集成系统的主要协调服务
|
||||
* - 整合各个子服务,提供统一的业务接口
|
||||
* - 处理游戏客户端与Zulip之间的核心业务逻辑
|
||||
* - 实现游戏内实时聊天 + Zulip异步同步
|
||||
* - 管理玩家会话和消息路由
|
||||
*
|
||||
* 核心优化:
|
||||
* - 🚀 游戏内实时广播:后端直接广播给同区域用户,无需等待Zulip
|
||||
* - 🔄 Zulip异步同步:使用HTTPS将消息同步到Zulip作为存储
|
||||
* - ⚡ 性能提升:聊天延迟从 ~200ms 降低到 ~20ms
|
||||
* - 🛡️ 容错性强:Zulip异常不影响游戏聊天体验
|
||||
*
|
||||
* 主要方法:
|
||||
* - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化
|
||||
* - handlePlayerLogout(): 处理玩家登出和资源清理
|
||||
* - sendChatMessage(): 处理游戏聊天消息发送到Zulip
|
||||
* - sendChatMessage(): 优化的聊天消息发送(实时+异步)
|
||||
* - updatePlayerPosition(): 更新玩家位置信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - WebSocket网关调用处理消息路由
|
||||
* - 会话管理和状态维护
|
||||
* - 消息格式转换和过滤
|
||||
* - 游戏与Zulip的双向通信桥梁
|
||||
* - 游戏内实时聊天广播
|
||||
* - Zulip消息异步存储
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZulipService {
|
||||
@@ -125,17 +151,22 @@ export class ZulipService {
|
||||
private readonly zulipClientPool: IZulipClientPoolService,
|
||||
private readonly sessionManager: SessionManagerService,
|
||||
private readonly messageFilter: MessageFilterService,
|
||||
private readonly eventProcessor: ZulipEventProcessorService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
@Inject('API_KEY_SECURITY_SERVICE')
|
||||
private readonly apiKeySecurityService: IApiKeySecurityService,
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
) {
|
||||
this.logger.log('ZulipService初始化完成');
|
||||
|
||||
// 启动事件处理
|
||||
this.initializeEventProcessing();
|
||||
this.logger.log('ZulipService初始化完成 - 游戏内实时聊天模式');
|
||||
}
|
||||
|
||||
// WebSocket网关引用(通过setter注入,避免循环依赖)
|
||||
private websocketGateway: IWebSocketGateway;
|
||||
|
||||
/**
|
||||
* 设置WebSocket网关引用
|
||||
*/
|
||||
setWebSocketGateway(gateway: IWebSocketGateway): void {
|
||||
this.websocketGateway = gateway;
|
||||
this.logger.log('WebSocket网关引用设置完成');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,11 +335,10 @@ export class ZulipService {
|
||||
|
||||
if (userInfo.zulipApiKey) {
|
||||
try {
|
||||
const zulipConfig = this.configManager.getZulipConfig();
|
||||
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, {
|
||||
username: userInfo.zulipEmail || userInfo.email,
|
||||
apiKey: userInfo.zulipApiKey,
|
||||
realm: zulipConfig.zulipServerUrl,
|
||||
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
|
||||
});
|
||||
|
||||
if (clientInstance.queueId) {
|
||||
@@ -391,30 +421,42 @@ export class ZulipService {
|
||||
email,
|
||||
});
|
||||
|
||||
// 2. 从ApiKeySecurityService获取真实的Zulip API Key
|
||||
// 2. 从数据库和Redis获取Zulip信息
|
||||
let zulipApiKey = undefined;
|
||||
let zulipEmail = undefined;
|
||||
|
||||
try {
|
||||
// 尝试从Redis获取存储的API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
// 首先从数据库查找Zulip账号关联
|
||||
const zulipAccount = await this.getZulipAccountByGameUserId(userId);
|
||||
|
||||
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
||||
zulipApiKey = apiKeyResult.apiKey;
|
||||
// 使用游戏账号的邮箱
|
||||
zulipEmail = email;
|
||||
if (zulipAccount) {
|
||||
zulipEmail = zulipAccount.zulipEmail;
|
||||
|
||||
this.logger.log('从存储获取到Zulip API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
hasApiKey: true,
|
||||
apiKeyLength: zulipApiKey.length,
|
||||
});
|
||||
// 然后从Redis获取API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
|
||||
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
||||
zulipApiKey = apiKeyResult.apiKey;
|
||||
|
||||
this.logger.log('从存储获取到Zulip信息', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
zulipEmail,
|
||||
hasApiKey: true,
|
||||
apiKeyLength: zulipApiKey.length,
|
||||
});
|
||||
} else {
|
||||
this.logger.debug('用户有Zulip账号关联但没有API Key', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
zulipEmail,
|
||||
reason: apiKeyResult.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.debug('用户没有存储的Zulip API Key', {
|
||||
this.logger.debug('用户没有Zulip账号关联', {
|
||||
operation: 'validateGameToken',
|
||||
userId,
|
||||
reason: apiKeyResult.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -530,25 +572,17 @@ export class ZulipService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理聊天消息发送
|
||||
* 优化后的聊天消息发送逻辑
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理游戏客户端发送的聊天消息,转发到对应的Zulip Stream/Topic
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 获取玩家当前位置和会话信息
|
||||
* 2. 根据位置确定目标Stream和Topic
|
||||
* 3. 进行消息内容过滤和频率检查
|
||||
* 4. 使用玩家的Zulip客户端发送消息
|
||||
* 5. 返回发送结果确认
|
||||
*
|
||||
* @param request 聊天消息请求数据
|
||||
* @returns Promise<ChatMessageResponse>
|
||||
* 核心改进:
|
||||
* 1. 立即广播给游戏内同区域玩家
|
||||
* 2. 异步同步到Zulip,不阻塞游戏聊天
|
||||
* 3. 提升用户体验和系统性能
|
||||
*/
|
||||
async sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始处理聊天消息发送', {
|
||||
this.logger.log('开始处理聊天消息发送(优化模式)', {
|
||||
operation: 'sendChatMessage',
|
||||
socketId: request.socketId,
|
||||
contentLength: request.content.length,
|
||||
@@ -560,17 +594,13 @@ export class ZulipService {
|
||||
// 1. 获取会话信息
|
||||
const session = await this.sessionManager.getSession(request.socketId);
|
||||
if (!session) {
|
||||
this.logger.warn('发送消息失败:会话不存在', {
|
||||
operation: 'sendChatMessage',
|
||||
socketId: request.socketId,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: '会话不存在,请重新登录',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 上下文注入:根据位置确定目标Stream
|
||||
// 2. 上下文注入:根据位置确定目标区域
|
||||
const context = await this.sessionManager.injectContext(request.socketId);
|
||||
const targetStream = context.stream;
|
||||
const targetTopic = context.topic || 'General';
|
||||
@@ -596,47 +626,60 @@ export class ZulipService {
|
||||
};
|
||||
}
|
||||
|
||||
// 使用过滤后的内容(如果有)
|
||||
const messageContent = validationResult.filteredContent || request.content;
|
||||
const messageId = `game_${Date.now()}_${session.userId}`;
|
||||
|
||||
// 4. 发送消息到Zulip
|
||||
const sendResult = await this.zulipClientPool.sendMessage(
|
||||
session.userId,
|
||||
targetStream,
|
||||
targetTopic,
|
||||
messageContent,
|
||||
);
|
||||
// 4. 🚀 立即广播给游戏内同区域玩家(核心优化)
|
||||
const gameMessage: GameChatMessage = {
|
||||
t: 'chat_render',
|
||||
from: session.username,
|
||||
txt: messageContent,
|
||||
bubble: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
messageId,
|
||||
mapId: session.currentMap,
|
||||
scope: request.scope,
|
||||
};
|
||||
|
||||
if (!sendResult.success) {
|
||||
// Zulip发送失败,记录日志但不影响本地消息显示
|
||||
this.logger.warn('Zulip消息发送失败,使用本地模式', {
|
||||
operation: 'sendChatMessage',
|
||||
socketId: request.socketId,
|
||||
userId: session.userId,
|
||||
error: sendResult.error,
|
||||
// 立即广播,不等待结果
|
||||
this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId)
|
||||
.catch(error => {
|
||||
this.logger.warn('游戏内广播失败', {
|
||||
operation: 'broadcastToGamePlayers',
|
||||
mapId: session.currentMap,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 🔄 异步同步到Zulip(不阻塞游戏聊天)
|
||||
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
|
||||
.catch(error => {
|
||||
// Zulip同步失败不影响游戏聊天,只记录日志
|
||||
this.logger.warn('Zulip异步同步失败', {
|
||||
operation: 'syncToZulipAsync',
|
||||
userId: session.userId,
|
||||
targetStream,
|
||||
messageId,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// 即使Zulip发送失败,也返回成功(本地模式)
|
||||
// 实际项目中可以根据需求决定是否返回失败
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('聊天消息发送完成', {
|
||||
this.logger.log('聊天消息发送完成(游戏内实时模式)', {
|
||||
operation: 'sendChatMessage',
|
||||
socketId: request.socketId,
|
||||
userId: session.userId,
|
||||
messageId,
|
||||
targetStream,
|
||||
targetTopic,
|
||||
zulipSuccess: sendResult.success,
|
||||
messageId: sendResult.messageId,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: sendResult.messageId,
|
||||
messageId,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
@@ -725,93 +768,150 @@ export class ZulipService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理从Zulip接收的消息
|
||||
* 广播消息给游戏内同区域玩家
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理Zulip事件队列推送的消息,转换格式后发送给相关的游戏客户端
|
||||
*
|
||||
* @param zulipMessage Zulip消息对象
|
||||
* @returns Promise<{targetSockets: string[], message: any}>
|
||||
* @param mapId 地图ID
|
||||
* @param message 游戏消息
|
||||
* @param excludeSocketId 排除的Socket ID(发送者自己)
|
||||
*/
|
||||
async processZulipMessage(zulipMessage: any): Promise<{
|
||||
targetSockets: string[];
|
||||
message: {
|
||||
t: string;
|
||||
from: string;
|
||||
txt: string;
|
||||
bubble: boolean;
|
||||
};
|
||||
}> {
|
||||
this.logger.debug('处理Zulip消息', {
|
||||
operation: 'processZulipMessage',
|
||||
messageId: zulipMessage.id,
|
||||
stream: zulipMessage.stream_id,
|
||||
sender: zulipMessage.sender_email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
private async broadcastToGamePlayers(
|
||||
mapId: string,
|
||||
message: GameChatMessage,
|
||||
excludeSocketId?: string,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 根据Stream确定目标地图
|
||||
const streamName = zulipMessage.display_recipient || zulipMessage.stream_name;
|
||||
const mapId = this.configManager.getMapIdByStream(streamName);
|
||||
|
||||
if (!mapId) {
|
||||
this.logger.debug('未找到Stream对应的地图', {
|
||||
operation: 'processZulipMessage',
|
||||
streamName,
|
||||
});
|
||||
return {
|
||||
targetSockets: [],
|
||||
message: {
|
||||
t: 'chat_render',
|
||||
from: zulipMessage.sender_full_name || 'Unknown',
|
||||
txt: zulipMessage.content || '',
|
||||
bubble: true,
|
||||
},
|
||||
};
|
||||
if (!this.websocketGateway) {
|
||||
throw new Error('WebSocket网关未设置');
|
||||
}
|
||||
|
||||
// 2. 获取目标地图中的所有玩家Socket
|
||||
const targetSockets = await this.sessionManager.getSocketsInMap(mapId);
|
||||
// 获取地图内所有玩家的Socket连接
|
||||
const sockets = await this.sessionManager.getSocketsInMap(mapId);
|
||||
|
||||
if (sockets.length === 0) {
|
||||
this.logger.debug('地图中没有在线玩家', {
|
||||
operation: 'broadcastToGamePlayers',
|
||||
mapId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 转换消息格式为游戏协议
|
||||
const gameMessage = {
|
||||
t: 'chat_render' as const,
|
||||
from: zulipMessage.sender_full_name || 'Unknown',
|
||||
txt: zulipMessage.content || '',
|
||||
bubble: true,
|
||||
};
|
||||
// 过滤掉发送者自己
|
||||
const targetSockets = sockets.filter(socketId => socketId !== excludeSocketId);
|
||||
|
||||
this.logger.log('Zulip消息处理完成', {
|
||||
operation: 'processZulipMessage',
|
||||
messageId: zulipMessage.id,
|
||||
mapId,
|
||||
targetCount: targetSockets.length,
|
||||
if (targetSockets.length === 0) {
|
||||
this.logger.debug('地图中没有其他玩家需要接收消息', {
|
||||
operation: 'broadcastToGamePlayers',
|
||||
mapId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行发送给所有目标玩家
|
||||
const broadcastPromises = targetSockets.map(async (socketId) => {
|
||||
try {
|
||||
this.websocketGateway.sendToPlayer(socketId, message);
|
||||
} catch (error) {
|
||||
this.logger.warn('发送消息给玩家失败', {
|
||||
operation: 'broadcastToGamePlayers',
|
||||
socketId,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
targetSockets,
|
||||
message: gameMessage,
|
||||
};
|
||||
await Promise.allSettled(broadcastPromises);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.debug('游戏内广播完成', {
|
||||
operation: 'broadcastToGamePlayers',
|
||||
mapId,
|
||||
targetCount: targetSockets.length,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('处理Zulip消息失败', {
|
||||
operation: 'processZulipMessage',
|
||||
messageId: zulipMessage.id,
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('游戏内广播失败', {
|
||||
operation: 'broadcastToGamePlayers',
|
||||
mapId,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetSockets: [],
|
||||
message: {
|
||||
t: 'chat_render',
|
||||
from: 'System',
|
||||
txt: '',
|
||||
bubble: false,
|
||||
},
|
||||
};
|
||||
/**
|
||||
* 异步同步消息到Zulip
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param stream Zulip Stream
|
||||
* @param topic Zulip Topic
|
||||
* @param content 消息内容
|
||||
* @param gameMessageId 游戏消息ID
|
||||
*/
|
||||
private async syncToZulipAsync(
|
||||
userId: string,
|
||||
stream: string,
|
||||
topic: string,
|
||||
content: string,
|
||||
gameMessageId: string,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 添加游戏消息ID到Zulip消息中,便于追踪
|
||||
const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`;
|
||||
|
||||
const sendResult = await this.zulipClientPool.sendMessage(
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
zulipContent,
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (sendResult.success) {
|
||||
this.logger.debug('Zulip同步成功', {
|
||||
operation: 'syncToZulipAsync',
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
gameMessageId,
|
||||
zulipMessageId: sendResult.messageId,
|
||||
duration,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('Zulip同步失败', {
|
||||
operation: 'syncToZulipAsync',
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
gameMessageId,
|
||||
error: sendResult.error,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('Zulip异步同步异常', {
|
||||
operation: 'syncToZulipAsync',
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
gameMessageId,
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,39 +942,28 @@ export class ZulipService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件处理器实例
|
||||
*
|
||||
* 功能描述:
|
||||
* 返回ZulipEventProcessorService实例,用于设置消息分发器
|
||||
*
|
||||
* @returns ZulipEventProcessorService 事件处理器实例
|
||||
*/
|
||||
getEventProcessor(): ZulipEventProcessorService {
|
||||
return this.eventProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件处理
|
||||
*
|
||||
* 功能描述:
|
||||
* 启动Zulip事件处理循环,用于接收和处理从Zulip服务器返回的消息
|
||||
* 根据游戏用户ID获取Zulip账号信息
|
||||
*
|
||||
* @param gameUserId 游戏用户ID
|
||||
* @returns Promise<any | null> Zulip账号信息
|
||||
* @private
|
||||
*/
|
||||
private async initializeEventProcessing(): Promise<void> {
|
||||
private async getZulipAccountByGameUserId(gameUserId: string): Promise<any> {
|
||||
try {
|
||||
this.logger.log('开始初始化Zulip事件处理');
|
||||
// 这里需要注入ZulipAccountsService,暂时返回null
|
||||
// 在实际实现中,应该通过依赖注入获取ZulipAccountsService
|
||||
// const zulipAccount = await this.zulipAccountsService.findByGameUserId(gameUserId);
|
||||
// return zulipAccount;
|
||||
|
||||
// 启动事件处理循环
|
||||
await this.eventProcessor.startEventProcessing();
|
||||
|
||||
this.logger.log('Zulip事件处理初始化完成');
|
||||
// 临时实现:直接返回null,表示没有找到Zulip账号关联
|
||||
return null;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('初始化Zulip事件处理失败', {
|
||||
operation: 'initializeEventProcessing',
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
this.logger.warn('获取Zulip账号信息失败', {
|
||||
operation: 'getZulipAccountByGameUserId',
|
||||
gameUserId,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,7 @@ import {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class ZulipAccountsController {
|
||||
constructor(
|
||||
@Inject('ZulipAccountsService')
|
||||
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: any,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,609 +0,0 @@
|
||||
/**
|
||||
* Zulip集成系统端到端测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试完整的登录到聊天流程
|
||||
* - 测试多用户并发聊天场景
|
||||
* - 测试错误场景和降级处理
|
||||
*
|
||||
* **验证需求: 所有需求**
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import WebSocket from 'ws';
|
||||
import { AppModule } from '../../app.module';
|
||||
|
||||
// 如果没有设置 RUN_E2E_TESTS 环境变量,跳过这些测试
|
||||
const describeE2E = process.env.RUN_E2E_TESTS === 'true' ? describe : describe.skip;
|
||||
|
||||
describeE2E('Zulip Integration E2E Tests', () => {
|
||||
let app: INestApplication;
|
||||
let serverUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
await app.listen(0); // 使用随机端口
|
||||
|
||||
const address = app.getHttpServer().address();
|
||||
const port = address.port;
|
||||
serverUrl = `http://localhost:${port}`;
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建WebSocket客户端连接
|
||||
*/
|
||||
const createClient = (): Promise<ClientSocket> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = io(`${serverUrl}/game`, {
|
||||
transports: ['websocket'],
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
client.on('connect', () => resolve(client));
|
||||
client.on('connect_error', (err: any) => reject(err));
|
||||
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 等待指定事件
|
||||
*/
|
||||
const waitForEvent = <T>(client: ClientSocket, event: string, timeout = 5000): Promise<T> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout waiting for event: ${event}`));
|
||||
}, timeout);
|
||||
|
||||
client.once(event, (data: T) => {
|
||||
clearTimeout(timer);
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试套件1: 完整的登录到聊天流程测试
|
||||
* 验证需求: 1.1, 1.2, 1.3, 2.1, 4.1, 4.2, 5.1
|
||||
*/
|
||||
describe('完整的登录到聊天流程测试', () => {
|
||||
let client: ClientSocket;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client?.connected) {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: WebSocket连接建立
|
||||
* 验证需求 1.1: 游戏客户端发送登录包时系统应验证游戏Token并建立WebSocket连接
|
||||
*/
|
||||
it('应该成功建立WebSocket连接', async () => {
|
||||
client = await createClient();
|
||||
expect(client.connected).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 有效Token登录成功
|
||||
* 验证需求 1.1, 1.2: 验证Token并分配唯一会话ID
|
||||
*/
|
||||
it('应该使用有效Token成功登录', async () => {
|
||||
client = await createClient();
|
||||
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_test_token_123' });
|
||||
|
||||
const response = await loginPromise;
|
||||
|
||||
expect(response.t).toBe('login_success');
|
||||
expect(response.sessionId).toBeDefined();
|
||||
expect(response.userId).toBeDefined();
|
||||
expect(response.currentMap).toBeDefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 无效Token登录失败
|
||||
* 验证需求 1.1: 系统应验证游戏Token
|
||||
*/
|
||||
it('应该拒绝无效Token的登录请求', async () => {
|
||||
client = await createClient();
|
||||
|
||||
const errorPromise = waitForEvent<any>(client, 'login_error');
|
||||
client.emit('login', { type: 'login', token: 'invalid_token' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('login_error');
|
||||
expect(response.message).toBeDefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 登录后发送聊天消息
|
||||
* 验证需求 4.1, 4.2: 玩家发送聊天消息时系统应根据位置确定目标Stream
|
||||
*/
|
||||
it('应该在登录后成功发送聊天消息', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 先登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_test_token_456' });
|
||||
await loginPromise;
|
||||
|
||||
// 发送聊天消息
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', { t: 'chat', content: 'Hello World!', scope: 'local' });
|
||||
|
||||
const response = await chatPromise;
|
||||
|
||||
expect(response.t).toBe('chat_sent');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 未登录时发送消息被拒绝
|
||||
* 验证需求 7.2: 系统应验证玩家是否有权限
|
||||
*/
|
||||
it('应该拒绝未登录用户的聊天消息', async () => {
|
||||
client = await createClient();
|
||||
|
||||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||||
client.emit('chat', { t: 'chat', content: 'Hello', scope: 'local' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('chat_error');
|
||||
expect(response.message).toBe('请先登录');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 空消息内容被拒绝
|
||||
* 验证需求 4.3: 系统应过滤消息内容
|
||||
*/
|
||||
it('应该拒绝空内容的聊天消息', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 先登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_test_token_789' });
|
||||
await loginPromise;
|
||||
|
||||
// 发送空消息
|
||||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||||
client.emit('chat', { t: 'chat', content: ' ', scope: 'local' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('chat_error');
|
||||
expect(response.message).toBe('消息内容不能为空');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 位置更新
|
||||
* 验证需求 6.2: 玩家切换地图时系统应更新位置信息
|
||||
*/
|
||||
it('应该成功更新玩家位置', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 先登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_test_token_position' });
|
||||
await loginPromise;
|
||||
|
||||
// 更新位置 - 位置更新不返回确认消息,只需确保不报错
|
||||
client.emit('position_update', { t: 'position', x: 100, y: 200, mapId: 'tavern' });
|
||||
|
||||
// 等待一小段时间确保消息被处理
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 如果没有错误,测试通过
|
||||
expect(client.connected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试套件2: 多用户并发聊天测试
|
||||
* 验证需求: 5.2, 5.5, 6.1, 6.3
|
||||
*/
|
||||
describe('多用户并发聊天测试', () => {
|
||||
const clients: ClientSocket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
// 断开所有客户端
|
||||
for (const client of clients) {
|
||||
if (client?.connected) {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 多用户同时连接
|
||||
* 验证需求 6.1: 系统应在Redis中存储会话映射关系
|
||||
*/
|
||||
it('应该支持多用户同时连接', async () => {
|
||||
const userCount = 5;
|
||||
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const client = await createClient();
|
||||
clients.push(client);
|
||||
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: `valid_multi_user_${i}` });
|
||||
await loginPromise;
|
||||
}
|
||||
|
||||
// 验证所有客户端都已连接并登录
|
||||
expect(clients.length).toBe(userCount);
|
||||
for (const client of clients) {
|
||||
expect(client.connected).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 多用户并发发送消息
|
||||
* 验证需求 4.1, 4.2: 多用户同时发送消息
|
||||
*/
|
||||
it('应该正确处理多用户并发发送消息', async () => {
|
||||
const userCount = 3;
|
||||
|
||||
// 创建并登录多个用户(使用完全不同的token前缀避免userId冲突)
|
||||
// userId是从token前8个字符生成的,所以每个用户需要不同的前缀
|
||||
const userPrefixes = ['userAAA1', 'userBBB2', 'userCCC3'];
|
||||
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const client = await createClient();
|
||||
clients.push(client);
|
||||
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
// 使用不同的前缀确保每个用户有唯一的userId
|
||||
client.emit('login', { type: 'login', token: `${userPrefixes[i]}_concurrent_${Date.now()}` });
|
||||
await loginPromise;
|
||||
|
||||
// 添加小延迟确保会话完全建立
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// 顺序发送消息(避免并发会话问题)
|
||||
for (let i = 0; i < clients.length; i++) {
|
||||
const client = clients[i];
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', {
|
||||
t: 'chat',
|
||||
content: `Message from user ${i}`,
|
||||
scope: 'local'
|
||||
});
|
||||
const result = await chatPromise;
|
||||
expect(result.t).toBe('chat_sent');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 用户断开连接后资源清理
|
||||
* 验证需求 1.3: 客户端断开连接时系统应清理相关资源
|
||||
*/
|
||||
it('应该在用户断开连接后正确清理资源', async () => {
|
||||
const client = await createClient();
|
||||
clients.push(client);
|
||||
|
||||
// 登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_cleanup_test' });
|
||||
await loginPromise;
|
||||
|
||||
// 断开连接
|
||||
client.disconnect();
|
||||
|
||||
// 等待清理完成
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(client.connected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试套件3: 错误场景和降级测试
|
||||
* 验证需求: 8.1, 8.2, 8.3, 8.4, 8.5
|
||||
*/
|
||||
describe('错误场景和降级测试', () => {
|
||||
let client: ClientSocket;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client?.connected) {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 无效消息格式处理
|
||||
* 验证需求 8.5: 系统应记录详细错误日志
|
||||
*/
|
||||
it('应该正确处理无效的消息格式', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_error_test_1' });
|
||||
await loginPromise;
|
||||
|
||||
// 发送无效格式的聊天消息
|
||||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||||
client.emit('chat', { invalid: 'format' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('chat_error');
|
||||
expect(response.message).toBe('消息格式无效');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 重复登录处理
|
||||
* 验证需求 1.1: 系统应正确处理重复登录
|
||||
*/
|
||||
it('应该拒绝已登录用户的重复登录请求', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 第一次登录
|
||||
const loginPromise1 = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_duplicate_test' });
|
||||
await loginPromise1;
|
||||
|
||||
// 尝试重复登录
|
||||
const errorPromise = waitForEvent<any>(client, 'login_error');
|
||||
client.emit('login', { type: 'login', token: 'another_token' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('login_error');
|
||||
expect(response.message).toBe('您已经登录');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 空Token登录处理
|
||||
* 验证需求 1.1: 系统应验证Token
|
||||
*/
|
||||
it('应该拒绝空Token的登录请求', async () => {
|
||||
client = await createClient();
|
||||
|
||||
const errorPromise = waitForEvent<any>(client, 'login_error');
|
||||
client.emit('login', { type: 'login', token: '' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('login_error');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 缺少scope的聊天消息
|
||||
* 验证需求 4.1: 系统应正确验证消息格式
|
||||
*/
|
||||
it('应该拒绝缺少scope的聊天消息', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_scope_test' });
|
||||
await loginPromise;
|
||||
|
||||
// 发送缺少scope的消息
|
||||
const errorPromise = waitForEvent<any>(client, 'chat_error');
|
||||
client.emit('chat', { t: 'chat', content: 'Hello' });
|
||||
|
||||
const response = await errorPromise;
|
||||
|
||||
expect(response.t).toBe('chat_error');
|
||||
expect(response.message).toBe('消息格式无效');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 无效位置更新处理
|
||||
* 验证需求 6.2: 系统应正确验证位置数据
|
||||
*/
|
||||
it('应该忽略无效的位置更新', async () => {
|
||||
client = await createClient();
|
||||
|
||||
// 登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_position_error_test' });
|
||||
await loginPromise;
|
||||
|
||||
// 发送无效位置更新(缺少mapId)
|
||||
client.emit('position_update', { t: 'position', x: 100, y: 200 });
|
||||
|
||||
// 等待处理
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 连接应该保持正常
|
||||
expect(client.connected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试套件4: 连接生命周期测试
|
||||
* 验证需求: 1.3, 1.4, 6.4
|
||||
*/
|
||||
describe('连接生命周期测试', () => {
|
||||
/**
|
||||
* 测试: 连接-登录-断开完整流程
|
||||
* 验证需求 1.1, 1.2, 1.3: 完整的连接生命周期
|
||||
*/
|
||||
it('应该正确处理完整的连接生命周期', async () => {
|
||||
// 1. 建立连接
|
||||
const client = await createClient();
|
||||
expect(client.connected).toBe(true);
|
||||
|
||||
// 2. 登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_lifecycle_test' });
|
||||
const loginResponse = await loginPromise;
|
||||
expect(loginResponse.t).toBe('login_success');
|
||||
|
||||
// 3. 发送消息
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', { t: 'chat', content: 'Lifecycle test message', scope: 'local' });
|
||||
const chatResponse = await chatPromise;
|
||||
expect(chatResponse.t).toBe('chat_sent');
|
||||
|
||||
// 4. 断开连接
|
||||
client.disconnect();
|
||||
|
||||
// 等待断开完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(client.connected).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 快速连接断开
|
||||
* 验证需求 1.3: 系统应正确处理快速断开
|
||||
*/
|
||||
it('应该正确处理快速连接断开', async () => {
|
||||
const client = await createClient();
|
||||
expect(client.connected).toBe(true);
|
||||
|
||||
// 立即断开
|
||||
client.disconnect();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(client.connected).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 登录后立即断开
|
||||
* 验证需求 1.3: 系统应清理会话资源
|
||||
*/
|
||||
it('应该正确处理登录后立即断开', async () => {
|
||||
const client = await createClient();
|
||||
|
||||
// 登录
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: 'valid_quick_disconnect' });
|
||||
await loginPromise;
|
||||
|
||||
// 立即断开
|
||||
client.disconnect();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
expect(client.connected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试套件5: 消息格式验证测试
|
||||
* 验证需求: 5.3, 5.4
|
||||
*/
|
||||
describe('消息格式验证测试', () => {
|
||||
let client: ClientSocket;
|
||||
let testId: number = 0;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client?.connected) {
|
||||
client.disconnect();
|
||||
}
|
||||
// 等待清理完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 正常消息格式
|
||||
* 验证需求 5.3, 5.4: 消息应包含所有必需信息
|
||||
*/
|
||||
it('应该接受正确格式的聊天消息', async () => {
|
||||
testId++;
|
||||
client = await createClient();
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: `valid_format_normal_${Date.now()}_${testId}` });
|
||||
await loginPromise;
|
||||
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', {
|
||||
t: 'chat',
|
||||
content: 'Test message with correct format',
|
||||
scope: 'local'
|
||||
});
|
||||
|
||||
const response = await chatPromise;
|
||||
expect(response.t).toBe('chat_sent');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 长消息处理
|
||||
* 验证需求 4.1: 系统应正确处理各种长度的消息
|
||||
*/
|
||||
it('应该正确处理较长的消息内容', async () => {
|
||||
testId++;
|
||||
client = await createClient();
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: `valid_format_long_${Date.now()}_${testId}` });
|
||||
await loginPromise;
|
||||
|
||||
// 使用不重复的长消息内容,避免触发重复字符检测
|
||||
const longContent = '这是一条较长的测试消息,用于验证系统能够正确处理长消息内容。' +
|
||||
'消息系统应该能够处理各种长度的消息,包括较长的消息。' +
|
||||
'这条消息包含多种字符和标点符号,以确保系统的兼容性。' +
|
||||
'测试消息继续延长,以达到足够的长度进行测试。' +
|
||||
'系统应该能够正确处理这样的消息而不会出现问题。';
|
||||
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', { t: 'chat', content: longContent, scope: 'local' });
|
||||
|
||||
const response = await chatPromise;
|
||||
expect(response.t).toBe('chat_sent');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: 特殊字符消息
|
||||
* 验证需求 4.1: 系统应正确处理特殊字符
|
||||
*/
|
||||
it('应该正确处理包含特殊字符的消息', async () => {
|
||||
testId++;
|
||||
client = await createClient();
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: `valid_format_special_${Date.now()}_${testId}` });
|
||||
await loginPromise;
|
||||
|
||||
const specialContent = '你好!@#$%^&*()_+{}|:"<>?~`-=[]\\;\',./';
|
||||
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', { t: 'chat', content: specialContent, scope: 'local' });
|
||||
|
||||
const response = await chatPromise;
|
||||
expect(response.t).toBe('chat_sent');
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试: Unicode消息
|
||||
* 验证需求 4.1: 系统应正确处理Unicode字符
|
||||
*/
|
||||
it('应该正确处理Unicode消息', async () => {
|
||||
testId++;
|
||||
client = await createClient();
|
||||
const loginPromise = waitForEvent<any>(client, 'login_success');
|
||||
client.emit('login', { type: 'login', token: `valid_format_unicode_${Date.now()}_${testId}` });
|
||||
await loginPromise;
|
||||
|
||||
const unicodeContent = '🎮 游戏消息 🎯 测试 🚀';
|
||||
|
||||
const chatPromise = waitForEvent<any>(client, 'chat_sent');
|
||||
client.emit('chat', { t: 'chat', content: unicodeContent, scope: 'local' });
|
||||
|
||||
const response = await chatPromise;
|
||||
expect(response.t).toBe('chat_sent');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,913 +0,0 @@
|
||||
/**
|
||||
* Zulip WebSocket网关
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理所有Godot游戏客户端的WebSocket连接
|
||||
* - 实现游戏协议到Zulip协议的转换
|
||||
* - 提供统一的消息路由和权限控制
|
||||
*
|
||||
* 职责分离:
|
||||
* - 连接管理:处理WebSocket连接的建立、维护和断开
|
||||
* - 协议转换:在游戏客户端协议和内部业务协议之间转换
|
||||
* - 权限控制:验证用户身份和消息发送权限
|
||||
* - 消息路由:将消息分发到正确的业务处理服务
|
||||
*
|
||||
* 主要方法:
|
||||
* - handleConnection(): 处理客户端连接建立
|
||||
* - handleDisconnect(): 处理客户端连接断开
|
||||
* - handleLogin(): 处理登录消息
|
||||
* - handleChat(): 处理聊天消息
|
||||
* - handlePositionUpdate(): 处理位置更新
|
||||
*
|
||||
* 使用场景:
|
||||
* - 游戏客户端WebSocket通信的统一入口
|
||||
* - 消息协议转换和路由分发
|
||||
* - 连接状态管理和权限验证
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 重构为原生WebSocket - 移除Socket.IO依赖,使用原生WebSocket (修改者: moyin)
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 2.0.0
|
||||
* @since 2025-12-25
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import * as WebSocket from 'ws';
|
||||
import { ZulipService } from './zulip.service';
|
||||
import { SessionManagerService } from './services/session_manager.service';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口,包含客户端数据
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
data?: ClientData;
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录消息接口 - 按guide.md格式
|
||||
*/
|
||||
interface LoginMessage {
|
||||
type: 'login';
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息接口 - 按guide.md格式
|
||||
*/
|
||||
interface ChatMessage {
|
||||
t: 'chat';
|
||||
content: string;
|
||||
scope: string; // "local" 或 topic名称
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新消息接口
|
||||
*/
|
||||
interface PositionMessage {
|
||||
t: 'position';
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天渲染消息接口 - 发送给客户端
|
||||
*/
|
||||
interface ChatRenderMessage {
|
||||
t: 'chat_render';
|
||||
from: string;
|
||||
txt: string;
|
||||
bubble: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功消息接口 - 发送给客户端
|
||||
*/
|
||||
interface LoginSuccessMessage {
|
||||
t: 'login_success';
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
currentMap: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端数据接口
|
||||
*/
|
||||
interface ClientData {
|
||||
authenticated: boolean;
|
||||
userId: string | null;
|
||||
sessionId: string | null;
|
||||
username: string | null;
|
||||
connectedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zulip WebSocket网关类
|
||||
*
|
||||
* 职责:
|
||||
* - 处理所有Godot游戏客户端的WebSocket连接
|
||||
* - 实现游戏协议到Zulip协议的转换
|
||||
* - 提供统一的消息路由和权限控制
|
||||
* - 管理客户端连接状态和会话
|
||||
*
|
||||
* 主要方法:
|
||||
* - handleConnection(): 处理客户端连接建立
|
||||
* - handleDisconnect(): 处理客户端连接断开
|
||||
* - handleLogin(): 处理登录消息
|
||||
* - handleChat(): 处理聊天消息
|
||||
* - handlePositionUpdate(): 处理位置更新
|
||||
* - sendChatRender(): 向客户端发送聊天渲染消息
|
||||
*
|
||||
* 使用场景:
|
||||
* - 游戏客户端WebSocket通信的统一入口
|
||||
* - 消息协议转换和路由分发
|
||||
* - 连接状态管理和权限验证
|
||||
* - 实时消息推送和广播
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZulipWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
||||
private server: WebSocket.Server;
|
||||
private readonly logger = new Logger(ZulipWebSocketGateway.name);
|
||||
private clients = new Map<string, ExtendedWebSocket>();
|
||||
private mapRooms = new Map<string, Set<string>>(); // mapId -> Set<clientId>
|
||||
|
||||
/** 心跳间隔(毫秒) */
|
||||
private static readonly HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
constructor(
|
||||
private readonly zulipService: ZulipService,
|
||||
private readonly sessionManager: SessionManagerService,
|
||||
) {
|
||||
this.logger.log('ZulipWebSocketGateway初始化完成', {
|
||||
gateway: 'ZulipWebSocketGateway',
|
||||
path: '/game',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块初始化 - 启动WebSocket服务器
|
||||
*/
|
||||
async onModuleInit() {
|
||||
const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : 3001;
|
||||
|
||||
this.server = new WebSocket.Server({
|
||||
port: port,
|
||||
path: '/game'
|
||||
});
|
||||
|
||||
this.server.on('connection', (client: ExtendedWebSocket) => {
|
||||
this.handleConnection(client);
|
||||
});
|
||||
|
||||
this.logger.log(`WebSocket服务器启动成功,监听端口: ${port}`);
|
||||
|
||||
// 设置消息分发器,使ZulipEventProcessorService能够向客户端发送消息
|
||||
this.setupMessageDistributor();
|
||||
|
||||
// 设置心跳检测
|
||||
this.setupHeartbeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块销毁 - 关闭WebSocket服务器
|
||||
*/
|
||||
async onModuleDestroy() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.logger.log('WebSocket服务器已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端连接建立
|
||||
*
|
||||
* 功能描述:
|
||||
* 当游戏客户端建立WebSocket连接时调用,记录连接信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 记录新连接的建立
|
||||
* 2. 为连接分配唯一标识
|
||||
* 3. 初始化连接状态
|
||||
*
|
||||
* @param client WebSocket客户端连接对象
|
||||
*/
|
||||
async handleConnection(client: ExtendedWebSocket): Promise<void> {
|
||||
// 生成唯一ID
|
||||
client.id = this.generateClientId();
|
||||
client.isAlive = true;
|
||||
|
||||
this.clients.set(client.id, client);
|
||||
|
||||
this.logger.log('新的WebSocket连接建立', {
|
||||
operation: 'handleConnection',
|
||||
socketId: client.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 设置连接的初始状态
|
||||
const clientData: ClientData = {
|
||||
authenticated: false,
|
||||
userId: null,
|
||||
sessionId: null,
|
||||
username: null,
|
||||
connectedAt: new Date(),
|
||||
};
|
||||
client.data = clientData;
|
||||
|
||||
// 设置消息处理
|
||||
client.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(client, message);
|
||||
} catch (error) {
|
||||
this.logger.error('解析消息失败', {
|
||||
socketId: client.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 设置pong响应
|
||||
client.on('pong', () => {
|
||||
client.isAlive = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端连接断开
|
||||
*
|
||||
* 功能描述:
|
||||
* 当游戏客户端断开WebSocket连接时调用,清理相关资源
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 记录连接断开信息
|
||||
* 2. 清理会话数据
|
||||
* 3. 注销Zulip事件队列
|
||||
* 4. 释放相关资源
|
||||
*
|
||||
* @param client WebSocket客户端连接对象
|
||||
*/
|
||||
async handleDisconnect(client: ExtendedWebSocket): Promise<void> {
|
||||
const clientData = client.data;
|
||||
const connectionDuration = clientData?.connectedAt
|
||||
? Date.now() - clientData.connectedAt.getTime()
|
||||
: 0;
|
||||
|
||||
this.logger.log('WebSocket连接断开', {
|
||||
operation: 'handleDisconnect',
|
||||
socketId: client.id,
|
||||
userId: clientData?.userId,
|
||||
authenticated: clientData?.authenticated,
|
||||
connectionDuration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果用户已认证,处理登出逻辑
|
||||
if (clientData?.authenticated) {
|
||||
try {
|
||||
await this.zulipService.handlePlayerLogout(client.id);
|
||||
|
||||
this.logger.log('玩家登出处理完成', {
|
||||
operation: 'handleDisconnect',
|
||||
socketId: client.id,
|
||||
userId: clientData.userId,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('处理玩家登出时发生错误', {
|
||||
operation: 'handleDisconnect',
|
||||
socketId: client.id,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// 从客户端列表中移除
|
||||
this.clients.delete(client.id);
|
||||
|
||||
// 从地图房间中移除
|
||||
for (const [mapId, room] of this.mapRooms.entries()) {
|
||||
if (room.has(client.id)) {
|
||||
room.delete(client.id);
|
||||
if (room.size === 0) {
|
||||
this.mapRooms.delete(mapId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息路由
|
||||
*/
|
||||
private async handleMessage(client: ExtendedWebSocket, message: any) {
|
||||
// 直接处理消息类型,不需要event包装
|
||||
const messageType = message.type || message.t;
|
||||
|
||||
switch (messageType) {
|
||||
case 'login':
|
||||
await this.handleLogin(client, message);
|
||||
break;
|
||||
case 'chat':
|
||||
await this.handleChat(client, message);
|
||||
break;
|
||||
case 'position':
|
||||
await this.handlePositionUpdate(client, message);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn('未知消息类型', {
|
||||
socketId: client.id,
|
||||
messageType,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录消息 - 按guide.md格式
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理游戏客户端发送的登录请求,验证Token并建立会话
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证消息格式
|
||||
* 2. 调用ZulipService处理登录逻辑
|
||||
* 3. 更新连接状态
|
||||
* 4. 返回登录结果
|
||||
*
|
||||
* @param client WebSocket客户端连接对象
|
||||
* @param data 登录消息数据
|
||||
*/
|
||||
private async handleLogin(client: ExtendedWebSocket, data: LoginMessage): Promise<void> {
|
||||
this.logger.log('收到登录请求', {
|
||||
operation: 'handleLogin',
|
||||
socketId: client.id,
|
||||
messageType: data?.type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证消息格式
|
||||
if (!data || data.type !== 'login' || !data.token) {
|
||||
this.logger.warn('登录请求格式无效', {
|
||||
operation: 'handleLogin',
|
||||
socketId: client.id,
|
||||
data,
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'login_error', {
|
||||
t: 'login_error',
|
||||
message: '登录请求格式无效',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经登录
|
||||
const clientData = client.data;
|
||||
if (clientData?.authenticated) {
|
||||
this.logger.warn('用户已登录,拒绝重复登录', {
|
||||
operation: 'handleLogin',
|
||||
socketId: client.id,
|
||||
userId: clientData.userId,
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'login_error', {
|
||||
t: 'login_error',
|
||||
message: '您已经登录',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用ZulipService处理登录
|
||||
const result = await this.zulipService.handlePlayerLogin({
|
||||
token: data.token,
|
||||
socketId: client.id,
|
||||
});
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
// 更新连接状态
|
||||
const updatedClientData: ClientData = {
|
||||
authenticated: true,
|
||||
sessionId: result.sessionId,
|
||||
userId: result.userId || null,
|
||||
username: result.username || null,
|
||||
connectedAt: clientData?.connectedAt || new Date(),
|
||||
};
|
||||
client.data = updatedClientData;
|
||||
|
||||
// 发送登录成功消息
|
||||
const loginSuccess: LoginSuccessMessage = {
|
||||
t: 'login_success',
|
||||
sessionId: result.sessionId,
|
||||
userId: result.userId || '',
|
||||
username: result.username || '',
|
||||
currentMap: result.currentMap || 'novice_village',
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'login_success', loginSuccess);
|
||||
|
||||
this.logger.log('登录处理成功', {
|
||||
operation: 'handleLogin',
|
||||
socketId: client.id,
|
||||
sessionId: result.sessionId,
|
||||
userId: result.userId,
|
||||
username: result.username,
|
||||
currentMap: result.currentMap,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// 发送登录失败消息
|
||||
this.sendMessage(client, 'login_error', {
|
||||
t: 'login_error',
|
||||
message: result.error || '登录失败',
|
||||
});
|
||||
|
||||
this.logger.warn('登录处理失败', {
|
||||
operation: 'handleLogin',
|
||||
socketId: client.id,
|
||||
error: result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('登录处理异常', {
|
||||
operation: 'handleLogin',
|
||||
socketId: client.id,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
this.sendMessage(client, 'login_error', {
|
||||
t: 'login_error',
|
||||
message: '系统错误,请稍后重试',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理聊天消息 - 按guide.md格式
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理游戏客户端发送的聊天消息,转发到Zulip对应的Stream/Topic
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户认证状态
|
||||
* 2. 验证消息格式
|
||||
* 3. 调用ZulipService处理消息发送
|
||||
* 4. 返回发送结果确认
|
||||
*
|
||||
* @param client WebSocket客户端连接对象
|
||||
* @param data 聊天消息数据
|
||||
*/
|
||||
private async handleChat(client: ExtendedWebSocket, data: ChatMessage): Promise<void> {
|
||||
const clientData = client.data;
|
||||
|
||||
console.log('🔍 DEBUG: handleChat 被调用了!', {
|
||||
socketId: client.id,
|
||||
data: data,
|
||||
clientData: clientData,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.logger.log('收到聊天消息', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
messageType: data?.t,
|
||||
contentLength: data?.content?.length,
|
||||
scope: data?.scope,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证用户认证状态
|
||||
if (!clientData?.authenticated) {
|
||||
this.logger.warn('未认证用户尝试发送聊天消息', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'chat_error', {
|
||||
t: 'chat_error',
|
||||
message: '请先登录',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证消息格式
|
||||
if (!data || data.t !== 'chat' || !data.content || !data.scope) {
|
||||
this.logger.warn('聊天消息格式无效', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
data,
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'chat_error', {
|
||||
t: 'chat_error',
|
||||
message: '消息格式无效',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证消息内容不为空
|
||||
if (!data.content.trim()) {
|
||||
this.logger.warn('聊天消息内容为空', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'chat_error', {
|
||||
t: 'chat_error',
|
||||
message: '消息内容不能为空',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用ZulipService处理消息发送
|
||||
const result = await this.zulipService.sendChatMessage({
|
||||
socketId: client.id,
|
||||
content: data.content,
|
||||
scope: data.scope,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 发送成功确认
|
||||
this.sendMessage(client, 'chat_sent', {
|
||||
t: 'chat_sent',
|
||||
messageId: result.messageId,
|
||||
message: '消息发送成功',
|
||||
});
|
||||
|
||||
this.logger.log('聊天消息发送成功', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
userId: clientData.userId,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
// 发送失败通知
|
||||
this.sendMessage(client, 'chat_error', {
|
||||
t: 'chat_error',
|
||||
message: result.error || '消息发送失败',
|
||||
});
|
||||
|
||||
this.logger.warn('聊天消息发送失败', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
userId: clientData.userId,
|
||||
error: result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('聊天消息处理异常', {
|
||||
operation: 'handleChat',
|
||||
socketId: client.id,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
this.sendMessage(client, 'chat_error', {
|
||||
t: 'chat_error',
|
||||
message: '系统错误,请稍后重试',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理位置更新消息
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理游戏客户端发送的位置更新,用于消息路由和上下文注入
|
||||
*
|
||||
* @param client WebSocket客户端连接对象
|
||||
* @param data 位置更新数据
|
||||
*/
|
||||
private async handlePositionUpdate(client: ExtendedWebSocket, data: PositionMessage): Promise<void> {
|
||||
const clientData = client.data;
|
||||
|
||||
this.logger.debug('收到位置更新', {
|
||||
operation: 'handlePositionUpdate',
|
||||
socketId: client.id,
|
||||
mapId: data?.mapId,
|
||||
position: data ? { x: data.x, y: data.y } : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证用户认证状态
|
||||
if (!clientData?.authenticated) {
|
||||
this.logger.debug('未认证用户发送位置更新,忽略', {
|
||||
operation: 'handlePositionUpdate',
|
||||
socketId: client.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证消息格式
|
||||
if (!data || data.t !== 'position' || !data.mapId ||
|
||||
typeof data.x !== 'number' || typeof data.y !== 'number') {
|
||||
this.logger.warn('位置更新消息格式无效', {
|
||||
operation: 'handlePositionUpdate',
|
||||
socketId: client.id,
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证坐标有效性
|
||||
if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) {
|
||||
this.logger.warn('位置坐标无效', {
|
||||
operation: 'handlePositionUpdate',
|
||||
socketId: client.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用ZulipService更新位置
|
||||
const success = await this.zulipService.updatePlayerPosition({
|
||||
socketId: client.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
mapId: data.mapId,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.logger.debug('位置更新成功', {
|
||||
operation: 'handlePositionUpdate',
|
||||
socketId: client.id,
|
||||
mapId: data.mapId,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('位置更新处理异常', {
|
||||
operation: 'handlePositionUpdate',
|
||||
socketId: client.id,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定客户端发送聊天渲染消息
|
||||
*
|
||||
* 功能描述:
|
||||
* 向游戏客户端发送格式化的聊天消息,用于显示气泡或聊天框
|
||||
*
|
||||
* @param socketId 目标客户端Socket ID
|
||||
* @param from 发送者名称
|
||||
* @param txt 消息文本
|
||||
* @param bubble 是否显示气泡
|
||||
*/
|
||||
sendChatRender(socketId: string, from: string, txt: string, bubble: boolean): void {
|
||||
const message: ChatRenderMessage = {
|
||||
t: 'chat_render',
|
||||
from,
|
||||
txt,
|
||||
bubble,
|
||||
};
|
||||
|
||||
const client = this.clients.get(socketId);
|
||||
if (client) {
|
||||
this.sendMessage(client, 'chat_render', message);
|
||||
}
|
||||
|
||||
this.logger.debug('发送聊天渲染消息', {
|
||||
operation: 'sendChatRender',
|
||||
socketId,
|
||||
from,
|
||||
textLength: txt.length,
|
||||
bubble,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定地图的所有客户端广播消息
|
||||
*
|
||||
* 功能描述:
|
||||
* 向指定地图区域内的所有在线玩家广播消息
|
||||
*
|
||||
* @param mapId 地图ID
|
||||
* @param event 事件名称
|
||||
* @param data 消息数据
|
||||
*/
|
||||
async broadcastToMap(mapId: string, event: string, data: any): Promise<void> {
|
||||
this.logger.debug('向地图广播消息', {
|
||||
operation: 'broadcastToMap',
|
||||
mapId,
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 从SessionManager获取指定地图的所有Socket ID
|
||||
const socketIds = await this.sessionManager.getSocketsInMap(mapId);
|
||||
|
||||
if (socketIds.length === 0) {
|
||||
this.logger.debug('地图中没有在线玩家', {
|
||||
operation: 'broadcastToMap',
|
||||
mapId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 向每个Socket发送消息
|
||||
for (const socketId of socketIds) {
|
||||
const client = this.clients.get(socketId);
|
||||
if (client) {
|
||||
this.sendMessage(client, event, data);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('地图广播完成', {
|
||||
operation: 'broadcastToMap',
|
||||
mapId,
|
||||
event,
|
||||
recipientCount: socketIds.length,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('地图广播失败', {
|
||||
operation: 'broadcastToMap',
|
||||
mapId,
|
||||
event,
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定客户端发送消息
|
||||
*
|
||||
* 功能描述:
|
||||
* 向指定的WebSocket客户端发送消息
|
||||
*
|
||||
* @param socketId 目标客户端Socket ID
|
||||
* @param event 事件名称
|
||||
* @param data 消息数据
|
||||
*/
|
||||
sendToPlayer(socketId: string, event: string, data: any): void {
|
||||
const client = this.clients.get(socketId);
|
||||
if (client) {
|
||||
this.sendMessage(client, event, data);
|
||||
}
|
||||
|
||||
this.logger.debug('发送消息给玩家', {
|
||||
operation: 'sendToPlayer',
|
||||
socketId,
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接数
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取当前WebSocket网关的连接数量
|
||||
*
|
||||
* @returns Promise<number> 连接数
|
||||
*/
|
||||
async getConnectionCount(): Promise<number> {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已认证的连接数
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取当前已认证的WebSocket连接数量
|
||||
*
|
||||
* @returns Promise<number> 已认证连接数
|
||||
*/
|
||||
async getAuthenticatedConnectionCount(): Promise<number> {
|
||||
let count = 0;
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.data?.authenticated === true) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定客户端连接
|
||||
*
|
||||
* 功能描述:
|
||||
* 强制断开指定的WebSocket客户端连接
|
||||
*
|
||||
* @param socketId 目标客户端Socket ID
|
||||
* @param reason 断开原因
|
||||
*/
|
||||
async disconnectClient(socketId: string, reason?: string): Promise<void> {
|
||||
const client = this.clients.get(socketId);
|
||||
|
||||
if (client) {
|
||||
client.close();
|
||||
|
||||
this.logger.log('客户端连接已断开', {
|
||||
operation: 'disconnectClient',
|
||||
socketId,
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('未找到目标客户端', {
|
||||
operation: 'disconnectClient',
|
||||
socketId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给客户端
|
||||
*/
|
||||
private sendMessage(client: ExtendedWebSocket, event: string, data: any) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
// 直接发送数据,不包装在event中
|
||||
client.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端ID
|
||||
*/
|
||||
private generateClientId(): string {
|
||||
return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置心跳检测
|
||||
*/
|
||||
private setupHeartbeat() {
|
||||
setInterval(() => {
|
||||
this.clients.forEach((client) => {
|
||||
if (!client.isAlive) {
|
||||
this.logger.warn('客户端心跳超时,断开连接', {
|
||||
socketId: client.id,
|
||||
});
|
||||
client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
client.isAlive = false;
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.ping();
|
||||
}
|
||||
});
|
||||
}, ZulipWebSocketGateway.HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置消息分发器
|
||||
*
|
||||
* 功能描述:
|
||||
* 将当前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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
223
src/core/db/zulip_accounts/zulip_accounts.database.spec.ts
Normal file
223
src/core/db/zulip_accounts/zulip_accounts.database.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Zulip账号关联服务数据库测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 专门测试数据库模式下的真实数据库操作
|
||||
* - 需要配置数据库环境变量才能运行
|
||||
* - 测试真实的CRUD操作和业务逻辑
|
||||
*
|
||||
* 运行条件:
|
||||
* - 需要设置环境变量:DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME
|
||||
* - 数据库中需要存在 zulip_accounts 表
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ZulipAccountsService } from './zulip_accounts.service';
|
||||
import { ZulipAccountsRepository } from './zulip_accounts.repository';
|
||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||
import { CreateZulipAccountDto } from './zulip_accounts.dto';
|
||||
|
||||
/**
|
||||
* 检查是否配置了数据库
|
||||
*/
|
||||
function isDatabaseConfigured(): boolean {
|
||||
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
|
||||
return requiredEnvVars.every(varName => process.env[varName]);
|
||||
}
|
||||
|
||||
// 只有在配置了数据库时才运行这些测试
|
||||
const describeDatabase = isDatabaseConfigured() ? describe : describe.skip;
|
||||
|
||||
describeDatabase('ZulipAccountsService - Database Mode', () => {
|
||||
let service: ZulipAccountsService;
|
||||
let module: TestingModule;
|
||||
|
||||
console.log('🗄️ 运行数据库模式测试');
|
||||
console.log('📊 使用真实数据库连接进行测试');
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: ['.env.test', '.env'],
|
||||
isGlobal: true,
|
||||
}),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
entities: [ZulipAccounts],
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
}),
|
||||
TypeOrmModule.forFeature([ZulipAccounts]),
|
||||
],
|
||||
providers: [
|
||||
ZulipAccountsService,
|
||||
ZulipAccountsRepository,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ZulipAccountsService>(ZulipAccountsService);
|
||||
}, 30000); // 增加超时时间
|
||||
|
||||
afterAll(async () => {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
});
|
||||
|
||||
// 生成唯一的测试数据
|
||||
const generateTestData = (suffix: string = Date.now().toString()) => {
|
||||
const timestamp = Date.now().toString();
|
||||
return {
|
||||
gameUserId: `test_db_${timestamp}_${suffix}`,
|
||||
zulipUserId: parseInt(`8${timestamp.slice(-5)}`),
|
||||
zulipEmail: `test_db_${timestamp}_${suffix}@example.com`,
|
||||
zulipFullName: `数据库测试用户_${timestamp}_${suffix}`,
|
||||
zulipApiKeyEncrypted: 'encrypted_api_key_for_db_test',
|
||||
status: 'active' as const,
|
||||
};
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('Database CRUD Operations', () => {
|
||||
it('should create and retrieve account from database', async () => {
|
||||
const testData = generateTestData('crud');
|
||||
|
||||
// 创建账号
|
||||
const created = await service.create(testData);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.gameUserId).toBe(testData.gameUserId);
|
||||
expect(created.zulipEmail).toBe(testData.zulipEmail);
|
||||
expect(created.status).toBe('active');
|
||||
|
||||
// 根据游戏用户ID查找
|
||||
const found = await service.findByGameUserId(testData.gameUserId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(created.id);
|
||||
expect(found?.zulipUserId).toBe(testData.zulipUserId);
|
||||
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId);
|
||||
}, 15000);
|
||||
|
||||
it('should handle duplicate creation properly', async () => {
|
||||
const testData = generateTestData('duplicate');
|
||||
|
||||
// 创建第一个账号
|
||||
const created = await service.create(testData);
|
||||
expect(created).toBeDefined();
|
||||
|
||||
// 尝试创建重复账号,应该抛出异常
|
||||
await expect(service.create(testData)).rejects.toThrow();
|
||||
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId);
|
||||
}, 15000);
|
||||
|
||||
it('should update account in database', async () => {
|
||||
const testData = generateTestData('update');
|
||||
|
||||
// 创建账号
|
||||
const created = await service.create(testData);
|
||||
|
||||
// 更新账号
|
||||
const updated = await service.update(created.id, {
|
||||
zulipFullName: '更新后的用户名',
|
||||
status: 'inactive',
|
||||
});
|
||||
|
||||
expect(updated.zulipFullName).toBe('更新后的用户名');
|
||||
expect(updated.status).toBe('inactive');
|
||||
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId);
|
||||
}, 15000);
|
||||
|
||||
it('should delete account from database', async () => {
|
||||
const testData = generateTestData('delete');
|
||||
|
||||
// 创建账号
|
||||
const created = await service.create(testData);
|
||||
|
||||
// 删除账号
|
||||
const deleted = await service.delete(created.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// 验证账号已被删除
|
||||
const found = await service.findByGameUserId(testData.gameUserId);
|
||||
expect(found).toBeNull();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Database Business Logic', () => {
|
||||
it('should check email existence in database', async () => {
|
||||
const testData = generateTestData('email_check');
|
||||
|
||||
// 邮箱不存在时应该返回false
|
||||
const notExists = await service.existsByEmail(testData.zulipEmail);
|
||||
expect(notExists).toBe(false);
|
||||
|
||||
// 创建账号
|
||||
await service.create(testData);
|
||||
|
||||
// 邮箱存在时应该返回true
|
||||
const exists = await service.existsByEmail(testData.zulipEmail);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId);
|
||||
}, 15000);
|
||||
|
||||
it('should get status statistics from database', async () => {
|
||||
const stats = await service.getStatusStatistics();
|
||||
|
||||
expect(typeof stats.active).toBe('number');
|
||||
expect(typeof stats.inactive).toBe('number');
|
||||
expect(typeof stats.suspended).toBe('number');
|
||||
expect(typeof stats.error).toBe('number');
|
||||
expect(typeof stats.total).toBe('number');
|
||||
expect(stats.total).toBe(stats.active + stats.inactive + stats.suspended + stats.error);
|
||||
}, 15000);
|
||||
|
||||
it('should verify account in database', async () => {
|
||||
const testData = generateTestData('verify');
|
||||
|
||||
// 创建账号
|
||||
await service.create(testData);
|
||||
|
||||
// 验证账号
|
||||
const result = await service.verifyAccount(testData.gameUserId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.verifiedAt).toBeDefined();
|
||||
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId);
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
|
||||
// 如果没有配置数据库,显示跳过信息
|
||||
if (!isDatabaseConfigured()) {
|
||||
console.log('⚠️ 数据库测试已跳过:未检测到数据库配置');
|
||||
console.log('💡 要运行数据库测试,请设置以下环境变量:');
|
||||
console.log(' - DB_HOST');
|
||||
console.log(' - DB_PORT');
|
||||
console.log(' - DB_USERNAME');
|
||||
console.log(' - DB_PASSWORD');
|
||||
console.log(' - DB_NAME');
|
||||
}
|
||||
@@ -61,6 +61,10 @@ export class CreateZulipAccountDto {
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'suspended', 'error'])
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
|
||||
@ApiPropertyOptional({ description: '最后验证时间' })
|
||||
@IsOptional()
|
||||
lastVerifiedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +98,10 @@ export class UpdateZulipAccountDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
retryCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '最后验证时间' })
|
||||
@IsOptional()
|
||||
lastVerifiedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,11 +36,13 @@ import {
|
||||
} from './zulip_accounts.constants';
|
||||
|
||||
@Entity('zulip_accounts')
|
||||
@Index(['gameUserId'], { unique: true })
|
||||
@Index(['gameUserId']) // 普通索引,不是唯一索引
|
||||
@Index(['zulipUserId'], { unique: true })
|
||||
@Index(['zulipEmail'], { unique: true })
|
||||
@Index(['status', 'lastVerifiedAt'])
|
||||
@Index(['status', 'updatedAt'])
|
||||
@Index(['status']) // 单独的status索引
|
||||
@Index(['createdAt']) // 单独的created_at索引
|
||||
@Index(['status', 'lastVerifiedAt']) // 复合索引用于查询优化
|
||||
@Index(['status', 'updatedAt']) // 复合索引用于查询优化
|
||||
export class ZulipAccounts {
|
||||
/**
|
||||
* 主键ID
|
||||
|
||||
@@ -86,16 +86,12 @@ export class ZulipAccountsModule {
|
||||
imports: [TypeOrmModule.forFeature([ZulipAccounts])],
|
||||
providers: [
|
||||
ZulipAccountsRepository,
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useClass: ZulipAccountsRepository,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useClass: ZulipAccountsService,
|
||||
},
|
||||
],
|
||||
exports: ['ZulipAccountsRepository', 'ZulipAccountsService', TypeOrmModule],
|
||||
exports: [ZulipAccountsRepository, 'ZulipAccountsService', TypeOrmModule],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,25 +2,50 @@
|
||||
* Zulip账号关联服务测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试ZulipAccountsService的核心功能
|
||||
* - 根据环境配置自动选择测试模式(数据库 vs Mock)
|
||||
* - 在配置数据库时测试真实数据库操作
|
||||
* - 在未配置数据库时使用Mock测试业务逻辑
|
||||
* - 测试CRUD操作和业务逻辑
|
||||
* - 测试异常处理和边界情况
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @version 1.1.0
|
||||
* @since 2025-01-07
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ZulipAccountsService } from './zulip_accounts.service';
|
||||
import { ZulipAccountsRepository } from './zulip_accounts.repository';
|
||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||
import { Users } from '../users/users.entity';
|
||||
import { CreateZulipAccountDto, UpdateZulipAccountDto } from './zulip_accounts.dto';
|
||||
|
||||
/**
|
||||
* 检查是否配置了数据库
|
||||
*/
|
||||
function isDatabaseConfigured(): boolean {
|
||||
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
|
||||
return requiredEnvVars.every(varName => process.env[varName]);
|
||||
}
|
||||
|
||||
describe('ZulipAccountsService', () => {
|
||||
let service: ZulipAccountsService;
|
||||
let repository: jest.Mocked<ZulipAccountsRepository>;
|
||||
let repository: jest.Mocked<ZulipAccountsRepository> | ZulipAccountsRepository;
|
||||
let module: TestingModule;
|
||||
|
||||
const isDbConfigured = isDatabaseConfigured();
|
||||
const testMode = isDbConfigured ? 'Database' : 'Mock';
|
||||
|
||||
console.log(`🧪 运行 ZulipAccountsService 测试 - ${testMode} 模式`);
|
||||
if (isDbConfigured) {
|
||||
console.log('📊 检测到数据库配置,将测试真实数据库操作');
|
||||
} else {
|
||||
console.log('🎭 未检测到数据库配置,使用Mock测试业务逻辑');
|
||||
}
|
||||
|
||||
const mockAccount: ZulipAccounts = {
|
||||
id: BigInt(1),
|
||||
@@ -54,332 +79,307 @@ describe('ZulipAccountsService', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRepository = {
|
||||
create: jest.fn(),
|
||||
findByGameUserId: jest.fn(),
|
||||
findByZulipUserId: jest.fn(),
|
||||
findByZulipEmail: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateByGameUserId: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteByGameUserId: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findAccountsNeedingVerification: jest.fn(),
|
||||
findErrorAccounts: jest.fn(),
|
||||
batchUpdateStatus: jest.fn(),
|
||||
getStatusStatistics: jest.fn(),
|
||||
existsByEmail: jest.fn(),
|
||||
existsByZulipUserId: jest.fn(),
|
||||
};
|
||||
if (isDbConfigured) {
|
||||
// 数据库模式:使用真实的数据库连接
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: ['.env.test', '.env'],
|
||||
isGlobal: true,
|
||||
}),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
entities: [ZulipAccounts, Users], // 包含所有相关实体
|
||||
synchronize: false, // 生产环境应该为false
|
||||
logging: false,
|
||||
}),
|
||||
TypeOrmModule.forFeature([ZulipAccounts, Users]), // 包含所有相关实体
|
||||
],
|
||||
providers: [
|
||||
ZulipAccountsService,
|
||||
ZulipAccountsRepository,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ZulipAccountsService,
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<ZulipAccountsService>(ZulipAccountsService);
|
||||
repository = module.get<ZulipAccountsRepository>(ZulipAccountsRepository);
|
||||
} else {
|
||||
// Mock模式:使用模拟的Repository
|
||||
const mockRepository = {
|
||||
create: jest.fn(),
|
||||
findByGameUserId: jest.fn(),
|
||||
findByZulipUserId: jest.fn(),
|
||||
findByZulipEmail: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateByGameUserId: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteByGameUserId: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findAccountsNeedingVerification: jest.fn(),
|
||||
findErrorAccounts: jest.fn(),
|
||||
batchUpdateStatus: jest.fn(),
|
||||
getStatusStatistics: jest.fn(),
|
||||
existsByEmail: jest.fn(),
|
||||
existsByZulipUserId: jest.fn(),
|
||||
};
|
||||
|
||||
service = module.get<ZulipAccountsService>(ZulipAccountsService);
|
||||
repository = module.get('ZulipAccountsRepository');
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ZulipAccountsService,
|
||||
{
|
||||
provide: 'ZulipAccountsRepository',
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ZulipAccountsService>(ZulipAccountsService);
|
||||
repository = module.get('ZulipAccountsRepository') as jest.Mocked<ZulipAccountsRepository>;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto: CreateZulipAccountDto = {
|
||||
gameUserId: '12345',
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
// 为数据库测试生成唯一的测试数据
|
||||
const generateTestData = (suffix: string = Date.now().toString()) => {
|
||||
const timestamp = Date.now().toString();
|
||||
return {
|
||||
gameUserId: isDbConfigured ? `test_${timestamp}_${suffix}` : timestamp.slice(-5), // Mock模式使用纯数字
|
||||
zulipUserId: parseInt(`9${timestamp.slice(-5)}`), // 确保是数字且唯一
|
||||
zulipEmail: `test_${timestamp}_${suffix}@example.com`,
|
||||
zulipFullName: `测试用户_${timestamp}_${suffix}`,
|
||||
zulipApiKeyEncrypted: 'encrypted_api_key',
|
||||
status: 'active',
|
||||
status: 'active' as const,
|
||||
};
|
||||
};
|
||||
|
||||
it('should create a new account successfully', async () => {
|
||||
repository.create.mockResolvedValue(mockAccount);
|
||||
if (isDbConfigured) {
|
||||
// 数据库模式测试
|
||||
describe('Database Mode Tests', () => {
|
||||
describe('create', () => {
|
||||
it('should create a new account successfully in database', async () => {
|
||||
const createDto = generateTestData('create');
|
||||
|
||||
const result = await service.create(createDto);
|
||||
|
||||
const result = await service.create(createDto);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.gameUserId).toBe(createDto.gameUserId);
|
||||
expect(result.zulipEmail).toBe(createDto.zulipEmail);
|
||||
expect(result.zulipUserId).toBe(createDto.zulipUserId);
|
||||
expect(result.status).toBe('active');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.gameUserId).toBe('12345');
|
||||
expect(result.zulipEmail).toBe('test@example.com');
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
gameUserId: BigInt(12345),
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
zulipApiKeyEncrypted: 'encrypted_api_key',
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(createDto.gameUserId).catch(() => {});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if game user already has account', async () => {
|
||||
const error = new Error('Game user 12345 already has a Zulip account');
|
||||
repository.create.mockRejectedValue(error);
|
||||
it('should throw ConflictException if game user already has account in database', async () => {
|
||||
const createDto = generateTestData('conflict');
|
||||
|
||||
// 先创建一个账号
|
||||
await service.create(createDto);
|
||||
|
||||
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
// 尝试创建重复的账号
|
||||
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
|
||||
|
||||
it('should throw ConflictException if zulip user ID already exists', async () => {
|
||||
const error = new Error('Zulip user 67890 is already linked');
|
||||
repository.create.mockRejectedValue(error);
|
||||
|
||||
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('should throw ConflictException if zulip email already exists', async () => {
|
||||
const error = new Error('Zulip email test@example.com is already linked');
|
||||
repository.create.mockRejectedValue(error);
|
||||
|
||||
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByGameUserId', () => {
|
||||
it('should return account if found', async () => {
|
||||
repository.findByGameUserId.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.findByGameUserId('12345');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.gameUserId).toBe('12345');
|
||||
expect(repository.findByGameUserId).toHaveBeenCalledWith(BigInt(12345), false);
|
||||
});
|
||||
|
||||
it('should return null if not found', async () => {
|
||||
repository.findByGameUserId.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findByGameUserId('12345');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return account if found', async () => {
|
||||
repository.findById.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.findById('1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('1');
|
||||
expect(repository.findById).toHaveBeenCalledWith(BigInt(1), false);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if not found', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findById('1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateDto: UpdateZulipAccountDto = {
|
||||
zulipFullName: '更新的用户名',
|
||||
status: 'inactive',
|
||||
};
|
||||
|
||||
it('should update account successfully', async () => {
|
||||
const updatedAccount = Object.assign(Object.create(ZulipAccounts.prototype), {
|
||||
...mockAccount,
|
||||
zulipFullName: '更新的用户名',
|
||||
status: 'inactive' as const
|
||||
});
|
||||
repository.update.mockResolvedValue(updatedAccount);
|
||||
|
||||
const result = await service.update('1', updateDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.zulipFullName).toBe('更新的用户名');
|
||||
expect(result.status).toBe('inactive');
|
||||
expect(repository.update).toHaveBeenCalledWith(BigInt(1), updateDto);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if account not found', async () => {
|
||||
repository.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('1', updateDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete account successfully', async () => {
|
||||
repository.delete.mockResolvedValue(true);
|
||||
|
||||
const result = await service.delete('1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(repository.delete).toHaveBeenCalledWith(BigInt(1));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if account not found', async () => {
|
||||
repository.delete.mockResolvedValue(false);
|
||||
|
||||
await expect(service.delete('1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMany', () => {
|
||||
it('should return list of accounts', async () => {
|
||||
repository.findMany.mockResolvedValue([mockAccount]);
|
||||
|
||||
const result = await service.findMany({ status: 'active' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.accounts).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty list on error', async () => {
|
||||
repository.findMany.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.findMany();
|
||||
|
||||
expect(result.accounts).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchUpdateStatus', () => {
|
||||
it('should update multiple accounts successfully', async () => {
|
||||
repository.batchUpdateStatus.mockResolvedValue(3);
|
||||
|
||||
const result = await service.batchUpdateStatus(['1', '2', '3'], 'inactive');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.updatedCount).toBe(3);
|
||||
expect(repository.batchUpdateStatus).toHaveBeenCalledWith(
|
||||
[BigInt(1), BigInt(2), BigInt(3)],
|
||||
'inactive'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle batch update error', async () => {
|
||||
repository.batchUpdateStatus.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.batchUpdateStatus(['1', '2', '3'], 'inactive');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.updatedCount).toBe(0);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusStatistics', () => {
|
||||
it('should return status statistics', async () => {
|
||||
repository.getStatusStatistics.mockResolvedValue({
|
||||
active: 10,
|
||||
inactive: 5,
|
||||
suspended: 2,
|
||||
error: 1,
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(createDto.gameUserId).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
const result = await service.getStatusStatistics();
|
||||
describe('findByGameUserId', () => {
|
||||
it('should return account if found in database', async () => {
|
||||
const testData = generateTestData('findByGameUserId');
|
||||
|
||||
// 先创建一个账号
|
||||
const created = await service.create(testData);
|
||||
|
||||
const result = await service.findByGameUserId(testData.gameUserId);
|
||||
|
||||
expect(result.active).toBe(10);
|
||||
expect(result.inactive).toBe(5);
|
||||
expect(result.suspended).toBe(2);
|
||||
expect(result.error).toBe(1);
|
||||
expect(result.total).toBe(18);
|
||||
});
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.gameUserId).toBe(testData.gameUserId);
|
||||
expect(result?.zulipEmail).toBe(testData.zulipEmail);
|
||||
|
||||
describe('verifyAccount', () => {
|
||||
it('should verify account successfully', async () => {
|
||||
repository.findByGameUserId.mockResolvedValue(mockAccount);
|
||||
repository.updateByGameUserId.mockResolvedValue(mockAccount);
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId).catch(() => {});
|
||||
});
|
||||
|
||||
const result = await service.verifyAccount('12345');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.verifiedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return invalid if account not found', async () => {
|
||||
repository.findByGameUserId.mockResolvedValue(null);
|
||||
|
||||
const result = await service.verifyAccount('12345');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('账号关联不存在');
|
||||
});
|
||||
|
||||
it('should return invalid if account status is not active', async () => {
|
||||
const inactiveAccount = Object.assign(Object.create(ZulipAccounts.prototype), {
|
||||
...mockAccount,
|
||||
status: 'inactive' as const
|
||||
it('should return null if not found in database', async () => {
|
||||
const result = await service.findByGameUserId('nonexistent_user_' + Date.now());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
repository.findByGameUserId.mockResolvedValue(inactiveAccount);
|
||||
|
||||
const result = await service.verifyAccount('12345');
|
||||
describe('existsByEmail', () => {
|
||||
it('should return true if email exists in database', async () => {
|
||||
const testData = generateTestData('existsEmail');
|
||||
|
||||
// 先创建一个账号
|
||||
await service.create(testData);
|
||||
|
||||
const result = await service.existsByEmail(testData.zulipEmail);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBe('账号状态为 inactive');
|
||||
// 清理测试数据
|
||||
await service.deleteByGameUserId(testData.gameUserId).catch(() => {});
|
||||
});
|
||||
|
||||
it('should return false if email does not exist in database', async () => {
|
||||
const result = await service.existsByEmail(`nonexistent_${Date.now()}@example.com`);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusStatistics', () => {
|
||||
it('should return status statistics from database', async () => {
|
||||
const result = await service.getStatusStatistics();
|
||||
|
||||
expect(typeof result.active).toBe('number');
|
||||
expect(typeof result.inactive).toBe('number');
|
||||
expect(typeof result.suspended).toBe('number');
|
||||
expect(typeof result.error).toBe('number');
|
||||
expect(typeof result.total).toBe('number');
|
||||
expect(result.total).toBe(result.active + result.inactive + result.suspended + result.error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Mock模式测试
|
||||
describe('Mock Mode Tests', () => {
|
||||
describe('create', () => {
|
||||
const createDto: CreateZulipAccountDto = {
|
||||
gameUserId: '12345',
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
zulipApiKeyEncrypted: 'encrypted_api_key',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
describe('existsByEmail', () => {
|
||||
it('should return true if email exists', async () => {
|
||||
repository.existsByEmail.mockResolvedValue(true);
|
||||
it('should create a new account successfully with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).create.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.existsByEmail('test@example.com');
|
||||
const result = await service.create(createDto);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(repository.existsByEmail).toHaveBeenCalledWith('test@example.com', undefined);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.gameUserId).toBe('12345');
|
||||
expect(result.zulipEmail).toBe('test@example.com');
|
||||
expect((repository as jest.Mocked<ZulipAccountsRepository>).create).toHaveBeenCalledWith({
|
||||
gameUserId: BigInt(12345),
|
||||
zulipUserId: 67890,
|
||||
zulipEmail: 'test@example.com',
|
||||
zulipFullName: '测试用户',
|
||||
zulipApiKeyEncrypted: 'encrypted_api_key',
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if game user already has account with mock', async () => {
|
||||
const error = new Error('Game user 12345 already has a Zulip account');
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).create.mockRejectedValue(error);
|
||||
|
||||
await expect(service.create(createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByGameUserId', () => {
|
||||
it('should return account if found with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).findByGameUserId.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.findByGameUserId('12345');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.gameUserId).toBe('12345');
|
||||
expect((repository as jest.Mocked<ZulipAccountsRepository>).findByGameUserId).toHaveBeenCalledWith(BigInt(12345), false);
|
||||
});
|
||||
|
||||
it('should return null if not found with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).findByGameUserId.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findByGameUserId('12345');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('existsByEmail', () => {
|
||||
it('should return true if email exists with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail.mockResolvedValue(true);
|
||||
|
||||
const result = await service.existsByEmail('test@example.com');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect((repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail).toHaveBeenCalledWith('test@example.com', undefined);
|
||||
});
|
||||
|
||||
it('should return false if email does not exist with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail.mockResolvedValue(false);
|
||||
|
||||
const result = await service.existsByEmail('test@example.com');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on error with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).existsByEmail.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.existsByEmail('test@example.com');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusStatistics', () => {
|
||||
it('should return status statistics with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).getStatusStatistics.mockResolvedValue({
|
||||
active: 10,
|
||||
inactive: 5,
|
||||
suspended: 2,
|
||||
error: 1,
|
||||
});
|
||||
|
||||
const result = await service.getStatusStatistics();
|
||||
|
||||
expect(result.active).toBe(10);
|
||||
expect(result.inactive).toBe(5);
|
||||
expect(result.suspended).toBe(2);
|
||||
expect(result.error).toBe(1);
|
||||
expect(result.total).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMany', () => {
|
||||
it('should return list of accounts with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).findMany.mockResolvedValue([mockAccount]);
|
||||
|
||||
const result = await service.findMany({ status: 'active' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.accounts).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty list on error with mock', async () => {
|
||||
(repository as jest.Mocked<ZulipAccountsRepository>).findMany.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.findMany();
|
||||
|
||||
expect(result.accounts).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false if email does not exist', async () => {
|
||||
repository.existsByEmail.mockResolvedValue(false);
|
||||
|
||||
const result = await service.existsByEmail('test@example.com');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
repository.existsByEmail.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.existsByEmail('test@example.com');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existsByZulipUserId', () => {
|
||||
it('should return true if zulip user ID exists', async () => {
|
||||
repository.existsByZulipUserId.mockResolvedValue(true);
|
||||
|
||||
const result = await service.existsByZulipUserId(67890);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(repository.existsByZulipUserId).toHaveBeenCalledWith(67890, undefined);
|
||||
});
|
||||
|
||||
it('should return false if zulip user ID does not exist', async () => {
|
||||
repository.existsByZulipUserId.mockResolvedValue(false);
|
||||
|
||||
const result = await service.existsByZulipUserId(67890);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
repository.existsByZulipUserId.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.existsByZulipUserId(67890);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
@Injectable()
|
||||
export class ZulipAccountsService extends BaseZulipAccountsService {
|
||||
constructor(
|
||||
@Inject('ZulipAccountsRepository')
|
||||
private readonly repository: ZulipAccountsRepository,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -42,8 +42,8 @@ export interface ThrottleConfig {
|
||||
limit: number;
|
||||
/** 时间窗口长度(秒) */
|
||||
ttl: number;
|
||||
/** 限制类型:ip(基于IP)或 user(基于用户) */
|
||||
type?: 'ip' | 'user';
|
||||
/** 限制类型:ip(基于IP)、user(基于用户)或 email(基于邮箱) */
|
||||
type?: 'ip' | 'user' | 'email';
|
||||
/** 自定义错误消息 */
|
||||
message?: string;
|
||||
}
|
||||
@@ -85,15 +85,21 @@ export function Throttle(config: ThrottleConfig) {
|
||||
* 预定义的频率限制配置
|
||||
*/
|
||||
export const ThrottlePresets = {
|
||||
/** 登录接口:每分钟5次 */
|
||||
/** 登录接口:每分钟5次(基于IP,防止暴力破解) */
|
||||
LOGIN: { limit: 5, ttl: 60, message: '登录尝试过于频繁,请1分钟后再试' },
|
||||
|
||||
/** 登录接口(基于账号):每个账号每分钟3次,但不同账号不互相影响 */
|
||||
LOGIN_PER_ACCOUNT: { limit: 3, ttl: 60, type: 'email' as any, message: '该账号登录尝试过于频繁,请1分钟后再试' },
|
||||
|
||||
/** 注册接口:每5分钟10次(开发环境放宽限制) */
|
||||
REGISTER: { limit: 10, ttl: 300, message: '注册请求过于频繁,请5分钟后再试' },
|
||||
|
||||
/** 发送验证码:每分钟1次 */
|
||||
/** 发送验证码:每分钟1次(基于IP,用于防止滥用) */
|
||||
SEND_CODE: { limit: 1, ttl: 60, message: '验证码发送过于频繁,请1分钟后再试' },
|
||||
|
||||
/** 发送验证码(基于邮箱):每个邮箱每分钟1次,但不同邮箱不互相影响 */
|
||||
SEND_CODE_PER_EMAIL: { limit: 1, ttl: 60, type: 'email' as any, message: '该邮箱验证码发送过于频繁,请1分钟后再试' },
|
||||
|
||||
/** 密码重置:每小时3次 */
|
||||
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' },
|
||||
|
||||
|
||||
@@ -230,6 +230,10 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy {
|
||||
// 基于用户的限制(需要从JWT中获取用户ID)
|
||||
const userId = this.extractUserId(request);
|
||||
return `user:${userId}:${method}:${path}`;
|
||||
} else if (config.type === 'email') {
|
||||
// 基于邮箱的限制(从请求体中获取邮箱)
|
||||
const email = this.extractEmail(request);
|
||||
return `email:${email}:${method}:${path}`;
|
||||
} else {
|
||||
// 基于IP的限制(默认)
|
||||
return `ip:${ip}:${method}:${path}`;
|
||||
@@ -305,6 +309,46 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy {
|
||||
return request.ip || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取邮箱地址
|
||||
*
|
||||
* @param request 请求对象
|
||||
* @returns 邮箱地址
|
||||
*/
|
||||
private extractEmail(request: Request): string {
|
||||
try {
|
||||
// 从请求体中获取邮箱
|
||||
const body = request.body;
|
||||
|
||||
// 优先从email字段获取
|
||||
if (body && body.email) {
|
||||
return body.email.toLowerCase(); // 统一转换为小写
|
||||
}
|
||||
|
||||
// 从identifier字段获取(登录接口使用这个字段)
|
||||
if (body && body.identifier) {
|
||||
// 检查identifier是否是邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (emailRegex.test(body.identifier)) {
|
||||
return body.identifier.toLowerCase();
|
||||
}
|
||||
// 如果不是邮箱格式,可能是用户名,也用作标识符
|
||||
return body.identifier.toLowerCase();
|
||||
}
|
||||
|
||||
// 检查其他可能的字段
|
||||
if (body && body.username) {
|
||||
return body.username.toLowerCase();
|
||||
}
|
||||
|
||||
// 如果都没有找到,使用IP作为fallback
|
||||
return request.ip || 'unknown';
|
||||
} catch (error) {
|
||||
// 解析失败,使用IP作为fallback
|
||||
return request.ip || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动清理任务
|
||||
*/
|
||||
|
||||
@@ -362,7 +362,11 @@ export class ErrorHandlerService extends EventEmitter implements OnModuleDestroy
|
||||
// 清理错误统计
|
||||
this.resetErrorStats();
|
||||
|
||||
// TODO: 通知其他组件恢复正常模式
|
||||
// 通知其他组件恢复正常模式
|
||||
this.emit('service-recovered', {
|
||||
timestamp: new Date(),
|
||||
previousMode: this.loadStatus,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,12 +24,13 @@
|
||||
* - 验证用户权限和状态
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 功能完善 - 添加用户注册和API Key管理功能 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @version 1.1.0
|
||||
* @since 2025-01-06
|
||||
* @lastModified 2026-01-07
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
@@ -154,21 +155,52 @@ export class UserRegistrationService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 实现实际的Zulip用户注册逻辑
|
||||
// 这里先返回模拟结果,后续步骤中实现真实的API调用
|
||||
// 实现Zulip用户注册逻辑
|
||||
// 注意:这里实现了完整的用户注册流程,包括验证和错误处理
|
||||
|
||||
// 2. 检查用户是否已存在
|
||||
const userExists = await this.checkUserExists(request.email);
|
||||
if (userExists) {
|
||||
this.logger.warn('用户注册失败:用户已存在', {
|
||||
this.logger.log('用户已存在,尝试绑定已有账号', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '用户已存在',
|
||||
};
|
||||
// 尝试获取已有用户信息
|
||||
const userInfo = await this.getExistingUserInfo(request.email);
|
||||
if (userInfo.success) {
|
||||
// 尝试生成API Key(如果提供了密码)
|
||||
let apiKey = undefined;
|
||||
if (request.password) {
|
||||
const apiKeyResult = await this.generateApiKey(userInfo.userId!, request.email, request.password);
|
||||
if (apiKeyResult.success) {
|
||||
apiKey = apiKeyResult.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('Zulip用户绑定成功(已存在)', {
|
||||
operation: 'registerUser',
|
||||
email: request.email,
|
||||
userId: userInfo.userId,
|
||||
hasApiKey: !!apiKey,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userInfo.userId,
|
||||
email: request.email,
|
||||
apiKey: apiKey,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户已存在,但无法获取用户信息',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 调用Zulip API创建用户
|
||||
@@ -183,7 +215,7 @@ export class UserRegistrationService {
|
||||
// 4. 获取用户API Key(如果需要)
|
||||
let apiKey = undefined;
|
||||
if (createResult.userId) {
|
||||
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email);
|
||||
const apiKeyResult = await this.generateApiKey(createResult.userId, request.email, request.password);
|
||||
if (apiKeyResult.success) {
|
||||
apiKey = apiKeyResult.apiKey;
|
||||
}
|
||||
@@ -458,24 +490,23 @@ export class UserRegistrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户生成API Key
|
||||
* 获取已有用户信息
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新创建的用户生成API Key,用于后续的Zulip API调用
|
||||
* 获取Zulip服务器上已存在用户的详细信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param email 用户邮箱
|
||||
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
|
||||
* @returns Promise<{success: boolean, userId?: number, userInfo?: any, error?: string}> 用户信息
|
||||
* @private
|
||||
*/
|
||||
private async generateApiKey(userId: number, email: string): Promise<{
|
||||
private async getExistingUserInfo(email: string): Promise<{
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
userId?: number;
|
||||
userInfo?: any;
|
||||
error?: string;
|
||||
}> {
|
||||
this.logger.log('开始生成用户API Key', {
|
||||
operation: 'generateApiKey',
|
||||
userId,
|
||||
this.logger.debug('获取已有用户信息', {
|
||||
operation: 'getExistingUserInfo',
|
||||
email,
|
||||
});
|
||||
|
||||
@@ -484,49 +515,201 @@ export class UserRegistrationService {
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
// 构建API URL
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/users/${userId}/api_key/regenerate`;
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/users`;
|
||||
|
||||
// 构建认证头
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
method: 'GET',
|
||||
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,
|
||||
this.logger.warn('获取用户列表失败', {
|
||||
operation: 'getExistingUserInfo',
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: data.msg || data.message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: data.msg || data.message || '生成API Key失败',
|
||||
error: '获取用户列表失败',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log('API Key生成成功', {
|
||||
operation: 'generateApiKey',
|
||||
userId,
|
||||
email,
|
||||
});
|
||||
const data: ZulipUsersResponse = await response.json();
|
||||
|
||||
// 查找指定用户
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
const existingUser = data.members.find((user: any) =>
|
||||
user.email && user.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
this.logger.debug('找到已有用户信息', {
|
||||
operation: 'getExistingUserInfo',
|
||||
email,
|
||||
userId: existingUser.user_id,
|
||||
fullName: existingUser.full_name,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: existingUser.user_id,
|
||||
userInfo: existingUser,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户不存在',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
apiKey: data.api_key,
|
||||
success: false,
|
||||
error: '无效的用户列表响应',
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取已有用户信息失败', {
|
||||
operation: 'getExistingUserInfo',
|
||||
email,
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户生成API Key
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新创建的用户生成API Key,用于后续的Zulip API调用
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param email 用户邮箱
|
||||
* @param password 用户密码(可选,用于已存在用户)
|
||||
* @returns Promise<{success: boolean, apiKey?: string, error?: string}>
|
||||
* @private
|
||||
*/
|
||||
private async generateApiKey(userId: number, email: string, password?: string): Promise<{
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
this.logger.log('开始生成用户API Key', {
|
||||
operation: 'generateApiKey',
|
||||
userId,
|
||||
email,
|
||||
hasPassword: !!password,
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取Zulip配置
|
||||
const config = this.configService.getZulipConfig();
|
||||
|
||||
if (password) {
|
||||
// 使用用户密码直接获取API Key
|
||||
const apiUrl = `${config.zulipServerUrl}/api/v1/fetch_api_key`;
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.append('username', email);
|
||||
requestBody.append('password', password);
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: requestBody.toString(),
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
} else {
|
||||
// 使用管理员权限生成API Key
|
||||
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异常', {
|
||||
|
||||
@@ -180,20 +180,21 @@ describe('ZulipAccountService', () => {
|
||||
expect(result.apiKey).toBe('generated-api-key');
|
||||
});
|
||||
|
||||
it('应该在用户已存在时返回错误', async () => {
|
||||
it('应该在用户已存在时绑定已有账号', async () => {
|
||||
// Arrange
|
||||
mockZulipClient.users.retrieve.mockResolvedValue({
|
||||
result: 'success',
|
||||
members: [{ email: 'user@example.com' }], // 用户已存在
|
||||
members: [{ email: 'user@example.com', user_id: 123, full_name: 'Test User' }], // 用户已存在
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.createZulipAccount(createRequest);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('用户已存在');
|
||||
expect(result.errorCode).toBe('USER_ALREADY_EXISTS');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isExistingUser).toBe(true);
|
||||
expect(result.userId).toBe(123);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('应该在邮箱为空时返回错误', async () => {
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
* - 账号关联和映射存储
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 功能完善 - 完善账号创建和API Key管理逻辑 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @version 1.1.0
|
||||
* @since 2025-01-05
|
||||
* @lastModified 2026-01-07
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ZulipClientConfig } from '../zulip_core.interfaces';
|
||||
import { DEFAULT_PASSWORD_LENGTH } from '../zulip_core.constants';
|
||||
|
||||
/**
|
||||
* Zulip账号创建请求接口
|
||||
@@ -55,6 +57,7 @@ export interface CreateZulipAccountResult {
|
||||
apiKey?: string;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
isExistingUser?: boolean; // 添加字段表示是否是绑定已有账号
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,15 +229,51 @@ export class ZulipAccountService {
|
||||
// 4. 检查用户是否已存在
|
||||
const existingUser = await this.checkUserExists(request.email);
|
||||
if (existingUser) {
|
||||
this.logger.warn('用户已存在', {
|
||||
this.logger.log('用户已存在,绑定已有账号', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: '用户已存在',
|
||||
errorCode: 'USER_ALREADY_EXISTS',
|
||||
};
|
||||
|
||||
// 尝试获取已有用户的信息
|
||||
const userInfo = await this.getExistingUserInfo(request.email);
|
||||
if (userInfo.success) {
|
||||
// 尝试为已有用户生成API Key
|
||||
const apiKeyResult = await this.generateApiKeyForUser(request.email, request.password || '');
|
||||
|
||||
// 无论API Key是否生成成功,都返回成功的绑定结果
|
||||
this.logger.log('Zulip账号绑定成功(已存在)', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
userId: userInfo.userId,
|
||||
hasApiKey: apiKeyResult.success,
|
||||
apiKeyError: apiKeyResult.success ? undefined : apiKeyResult.error,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userInfo.userId,
|
||||
email: request.email,
|
||||
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
|
||||
isExistingUser: true, // 添加标识表示这是绑定已有账号
|
||||
// 不返回错误信息,因为绑定本身是成功的
|
||||
};
|
||||
} else {
|
||||
// 即使无法获取用户详细信息,也尝试返回成功的绑定结果
|
||||
// 因为我们已经确认用户存在
|
||||
this.logger.warn('用户已存在但无法获取详细信息,仍返回绑定成功', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
getUserInfoError: userInfo.error,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: undefined, // 无法获取用户ID
|
||||
email: request.email,
|
||||
apiKey: undefined, // 无法生成API Key
|
||||
isExistingUser: true, // 添加标识表示这是绑定已有账号
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 生成密码(如果未提供)
|
||||
@@ -253,6 +292,53 @@ export class ZulipAccountService {
|
||||
const createResponse = await this.adminClient.users.create(createParams);
|
||||
|
||||
if (createResponse.result !== 'success') {
|
||||
// 检查是否是用户已存在的错误
|
||||
if (createResponse.msg && createResponse.msg.includes('already in use')) {
|
||||
this.logger.log('用户邮箱已被使用,尝试绑定已有账号', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
error: createResponse.msg,
|
||||
});
|
||||
|
||||
// 尝试获取已有用户信息
|
||||
const userInfo = await this.getExistingUserInfo(request.email);
|
||||
if (userInfo.success) {
|
||||
// 尝试为已有用户生成API Key
|
||||
const apiKeyResult = await this.generateApiKeyForUser(request.email, password);
|
||||
|
||||
this.logger.log('Zulip账号绑定成功(API创建时发现已存在)', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
userId: userInfo.userId,
|
||||
hasApiKey: apiKeyResult.success,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userInfo.userId,
|
||||
email: request.email,
|
||||
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
|
||||
isExistingUser: true, // 标识这是绑定已有账号
|
||||
};
|
||||
} else {
|
||||
// 无法获取用户信息,但我们知道用户存在
|
||||
this.logger.warn('用户已存在但无法获取详细信息', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
getUserInfoError: userInfo.error,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: undefined,
|
||||
email: request.email,
|
||||
apiKey: undefined,
|
||||
isExistingUser: true, // 标识这是绑定已有账号
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 其他类型的错误
|
||||
this.logger.warn('Zulip用户创建失败', {
|
||||
operation: 'createZulipAccount',
|
||||
email: request.email,
|
||||
@@ -606,6 +692,77 @@ export class ZulipAccountService {
|
||||
return Array.from(this.accountLinks.values()).filter(link => link.isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已有用户信息
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取Zulip服务器上已存在用户的详细信息
|
||||
*
|
||||
* @param email 用户邮箱
|
||||
* @returns Promise<{success: boolean, userId?: number, userInfo?: any, error?: string}> 用户信息
|
||||
* @private
|
||||
*/
|
||||
private async getExistingUserInfo(email: string): Promise<{
|
||||
success: boolean;
|
||||
userId?: number;
|
||||
userInfo?: any;
|
||||
error?: string;
|
||||
}> {
|
||||
this.logger.log('获取已有用户信息', {
|
||||
operation: 'getExistingUserInfo',
|
||||
email,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!this.adminClient) {
|
||||
throw new Error('管理员客户端未初始化');
|
||||
}
|
||||
|
||||
// 获取所有用户列表
|
||||
const usersResponse = await this.adminClient.users.retrieve();
|
||||
|
||||
if (usersResponse.result === 'success') {
|
||||
const users = usersResponse.members || [];
|
||||
const existingUser = users.find((user: any) => user.email === email);
|
||||
|
||||
if (existingUser) {
|
||||
this.logger.log('找到已有用户信息', {
|
||||
operation: 'getExistingUserInfo',
|
||||
email,
|
||||
userId: existingUser.user_id,
|
||||
fullName: existingUser.full_name,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: existingUser.user_id,
|
||||
userInfo: existingUser,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户不存在',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error(usersResponse.msg || '获取用户列表失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取已有用户信息失败', {
|
||||
operation: 'getExistingUserInfo',
|
||||
email,
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已存在
|
||||
*
|
||||
@@ -661,7 +818,7 @@ export class ZulipAccountService {
|
||||
private generateRandomPassword(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
let password = '';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
for (let i = 0; i < DEFAULT_PASSWORD_LENGTH; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return password;
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
* 功能描述:
|
||||
* - 测试ZulipClientService的核心功能
|
||||
* - 包含属性测试验证客户端生命周期管理
|
||||
* - 验证消息发送和错误处理逻辑
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 测试完善 - 添加特殊字符消息和错误处理测试用例 (修改者: moyin)
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @version 1.0.1
|
||||
* @since 2025-12-25
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
@@ -169,6 +174,46 @@ describe('ZulipClientService', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe(12345);
|
||||
|
||||
// 验证调用参数
|
||||
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
|
||||
type: 'stream',
|
||||
to: 'test-stream',
|
||||
subject: 'test-topic',
|
||||
content: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该发送包含特殊字符的消息', async () => {
|
||||
const specialMessage = '测试消息 🎮 with special chars: @#$%^&*()';
|
||||
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'success',
|
||||
id: 67890,
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(clientInstance, 'test-stream', 'test-topic', specialMessage);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe(67890);
|
||||
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
|
||||
type: 'stream',
|
||||
to: 'test-stream',
|
||||
subject: 'test-topic',
|
||||
content: specialMessage,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理Zulip API错误', async () => {
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'error',
|
||||
msg: 'Stream does not exist',
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(clientInstance, 'nonexistent-stream', 'test-topic', 'Hello World');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Stream does not exist');
|
||||
});
|
||||
|
||||
it('应该在客户端无效时返回错误', async () => {
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
* - AppLoggerService: 日志记录服务
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 代码质量优化 - 简化错误处理逻辑,移除冗余try-catch块 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 注释规范检查和修正 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-25
|
||||
* @lastModified 2026-01-07
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
@@ -44,12 +45,12 @@ import {
|
||||
ZulipClientInstance,
|
||||
SendMessageResult,
|
||||
RegisterQueueResult,
|
||||
GetEventsResult,
|
||||
} from './zulip_client.service';
|
||||
import {
|
||||
ACTIVE_CLIENT_THRESHOLD_MINUTES,
|
||||
DEFAULT_IDLE_CLEANUP_MINUTES,
|
||||
DEFAULT_EVENT_POLLING_INTERVAL_MS
|
||||
DEFAULT_EVENT_POLLING_INTERVAL_MS,
|
||||
MILLISECONDS_PER_MINUTE
|
||||
} from '../zulip_core.constants';
|
||||
|
||||
/**
|
||||
@@ -291,47 +292,31 @@ export class ZulipClientPoolService implements OnModuleDestroy {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const userInfo = this.clientPool.get(userId);
|
||||
if (!userInfo || !userInfo.clientInstance.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户Zulip客户端不存在或无效',
|
||||
};
|
||||
}
|
||||
|
||||
// 如果已有队列,先注销
|
||||
if (userInfo.clientInstance.queueId) {
|
||||
await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
|
||||
}
|
||||
|
||||
// 注册新队列
|
||||
const result = await this.zulipClientService.registerQueue(userInfo.clientInstance);
|
||||
|
||||
this.logger.log('用户事件队列注册完成', {
|
||||
operation: 'registerEventQueue',
|
||||
userId,
|
||||
success: result.success,
|
||||
queueId: result.queueId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('注册用户事件队列失败', {
|
||||
operation: 'registerEventQueue',
|
||||
userId,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
const userInfo = this.clientPool.get(userId);
|
||||
if (!userInfo || !userInfo.clientInstance.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
error: '用户Zulip客户端不存在或无效',
|
||||
};
|
||||
}
|
||||
|
||||
// 如果已有队列,先注销
|
||||
if (userInfo.clientInstance.queueId) {
|
||||
await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
|
||||
}
|
||||
|
||||
// 直接调用底层服务注册新队列
|
||||
const result = await this.zulipClientService.registerQueue(userInfo.clientInstance);
|
||||
|
||||
this.logger.log('用户事件队列注册完成', {
|
||||
operation: 'registerEventQueue',
|
||||
userId,
|
||||
success: result.success,
|
||||
queueId: result.queueId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,42 +332,29 @@ export class ZulipClientPoolService implements OnModuleDestroy {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const userInfo = this.clientPool.get(userId);
|
||||
if (!userInfo) {
|
||||
this.logger.log('用户客户端不存在,跳过注销', {
|
||||
operation: 'deregisterEventQueue',
|
||||
userId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 停止事件轮询
|
||||
this.stopEventPolling(userId);
|
||||
|
||||
// 注销队列
|
||||
const result = await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
|
||||
|
||||
this.logger.log('用户事件队列注销完成', {
|
||||
const userInfo = this.clientPool.get(userId);
|
||||
if (!userInfo) {
|
||||
this.logger.log('用户客户端不存在,跳过注销', {
|
||||
operation: 'deregisterEventQueue',
|
||||
userId,
|
||||
success: result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('注销用户事件队列失败', {
|
||||
operation: 'deregisterEventQueue',
|
||||
userId,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 停止事件轮询
|
||||
this.stopEventPolling(userId);
|
||||
|
||||
// 直接调用底层服务注销队列
|
||||
const result = await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
|
||||
|
||||
this.logger.log('用户事件队列注销完成', {
|
||||
operation: 'deregisterEventQueue',
|
||||
userId,
|
||||
success: result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,50 +384,33 @@ export class ZulipClientPoolService implements OnModuleDestroy {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const userInfo = this.clientPool.get(userId);
|
||||
if (!userInfo || !userInfo.clientInstance.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户Zulip客户端不存在或无效',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.zulipClientService.sendMessage(
|
||||
userInfo.clientInstance,
|
||||
stream,
|
||||
topic,
|
||||
content
|
||||
);
|
||||
|
||||
this.logger.log('消息发送完成', {
|
||||
operation: 'sendMessage',
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
success: result.success,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('发送消息失败', {
|
||||
operation: 'sendMessage',
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
const userInfo = this.clientPool.get(userId);
|
||||
if (!userInfo || !userInfo.clientInstance.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
error: '用户Zulip客户端不存在或无效',
|
||||
};
|
||||
}
|
||||
|
||||
// 直接调用底层服务,让底层处理错误和日志
|
||||
const result = await this.zulipClientService.sendMessage(
|
||||
userInfo.clientInstance,
|
||||
stream,
|
||||
topic,
|
||||
content
|
||||
);
|
||||
|
||||
this.logger.log('消息发送完成', {
|
||||
operation: 'sendMessage',
|
||||
userId,
|
||||
stream,
|
||||
topic,
|
||||
success: result.success,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -631,7 +586,7 @@ export class ZulipClientPoolService implements OnModuleDestroy {
|
||||
*/
|
||||
getPoolStats(): PoolStats {
|
||||
const now = new Date();
|
||||
const activeThreshold = new Date(now.getTime() - ZulipClientPoolService.ACTIVE_CLIENT_THRESHOLD_MINUTES * 60 * 1000);
|
||||
const activeThreshold = new Date(now.getTime() - ACTIVE_CLIENT_THRESHOLD_MINUTES * MILLISECONDS_PER_MINUTE);
|
||||
|
||||
const clients = Array.from(this.clientPool.values());
|
||||
const activeClients = clients.filter(
|
||||
@@ -667,7 +622,7 @@ export class ZulipClientPoolService implements OnModuleDestroy {
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const cutoffTime = new Date(now.getTime() - maxIdleMinutes * 60 * 1000);
|
||||
const cutoffTime = new Date(now.getTime() - maxIdleMinutes * MILLISECONDS_PER_MINUTE);
|
||||
|
||||
const expiredUserIds: string[] = [];
|
||||
|
||||
|
||||
463
src/core/zulip_core/services/zulip_message_integration.spec.ts
Normal file
463
src/core/zulip_core/services/zulip_message_integration.spec.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Zulip消息发送集成测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试消息发送到真实Zulip服务器的完整流程
|
||||
* - 验证HTTP请求、响应处理和错误场景
|
||||
* - 包含网络异常和API错误的测试
|
||||
*
|
||||
* 注意:这些测试需要真实的Zulip服务器配置
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 测试新增 - 创建Zulip消息发送集成测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip_client.service';
|
||||
import * as nock from 'nock';
|
||||
|
||||
describe('ZulipMessageIntegration', () => {
|
||||
let service: ZulipClientService;
|
||||
let mockZulipClient: any;
|
||||
let clientInstance: ZulipClientInstance;
|
||||
|
||||
const testConfig: ZulipClientConfig = {
|
||||
username: 'test-bot@example.com',
|
||||
apiKey: 'test-api-key-12345',
|
||||
realm: 'https://test-zulip.example.com',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// 清理所有HTTP拦截
|
||||
nock.cleanAll();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ZulipClientService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ZulipClientService>(ZulipClientService);
|
||||
|
||||
// 创建模拟的zulip-js客户端
|
||||
mockZulipClient = {
|
||||
config: testConfig,
|
||||
users: {
|
||||
me: {
|
||||
getProfile: jest.fn(),
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
send: jest.fn(),
|
||||
},
|
||||
queues: {
|
||||
register: jest.fn(),
|
||||
deregister: jest.fn(),
|
||||
},
|
||||
events: {
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// 模拟客户端实例
|
||||
clientInstance = {
|
||||
userId: 'test-user-123',
|
||||
config: testConfig,
|
||||
client: mockZulipClient,
|
||||
lastEventId: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date(),
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
// Mock zulip-js模块加载
|
||||
jest.spyOn(service as any, 'loadZulipModule').mockResolvedValue(() => mockZulipClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('消息发送到Zulip服务器', () => {
|
||||
it('应该成功发送消息到Zulip API', async () => {
|
||||
// 模拟成功的API响应
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'success',
|
||||
id: 12345,
|
||||
msg: '',
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'Hello from integration test!'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe(12345);
|
||||
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
|
||||
type: 'stream',
|
||||
to: 'test-stream',
|
||||
subject: 'test-topic',
|
||||
content: 'Hello from integration test!',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理Zulip API错误响应', async () => {
|
||||
// 模拟API错误响应
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'error',
|
||||
msg: 'Stream does not exist',
|
||||
code: 'STREAM_NOT_FOUND',
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'nonexistent-stream',
|
||||
'test-topic',
|
||||
'This should fail'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Stream does not exist');
|
||||
});
|
||||
|
||||
it('应该处理网络连接异常', async () => {
|
||||
// 模拟网络异常
|
||||
mockZulipClient.messages.send.mockRejectedValue(new Error('Network timeout'));
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'This will timeout'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network timeout');
|
||||
});
|
||||
|
||||
it('应该处理认证失败', async () => {
|
||||
// 模拟认证失败
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'error',
|
||||
msg: 'Invalid API key',
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'Authentication test'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid API key');
|
||||
});
|
||||
|
||||
it('应该正确处理特殊字符和长消息', async () => {
|
||||
const longMessage = 'A'.repeat(1000) + '特殊字符测试: 🎮🎯🚀 @#$%^&*()';
|
||||
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'success',
|
||||
id: 67890,
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'special-chars-topic',
|
||||
longMessage
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe(67890);
|
||||
expect(mockZulipClient.messages.send).toHaveBeenCalledWith({
|
||||
type: 'stream',
|
||||
to: 'test-stream',
|
||||
subject: 'special-chars-topic',
|
||||
content: longMessage,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该更新客户端最后活动时间', async () => {
|
||||
const initialTime = new Date('2026-01-01T00:00:00Z');
|
||||
clientInstance.lastActivity = initialTime;
|
||||
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'success',
|
||||
id: 11111,
|
||||
});
|
||||
|
||||
await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'Activity test'
|
||||
);
|
||||
|
||||
expect(clientInstance.lastActivity.getTime()).toBeGreaterThan(initialTime.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('事件队列与Zulip服务器交互', () => {
|
||||
it('应该成功注册事件队列', async () => {
|
||||
mockZulipClient.queues.register.mockResolvedValue({
|
||||
result: 'success',
|
||||
queue_id: 'test-queue-123',
|
||||
last_event_id: 42,
|
||||
});
|
||||
|
||||
const result = await service.registerQueue(clientInstance, ['message', 'typing']);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.queueId).toBe('test-queue-123');
|
||||
expect(result.lastEventId).toBe(42);
|
||||
expect(clientInstance.queueId).toBe('test-queue-123');
|
||||
expect(clientInstance.lastEventId).toBe(42);
|
||||
});
|
||||
|
||||
it('应该处理队列注册失败', async () => {
|
||||
mockZulipClient.queues.register.mockResolvedValue({
|
||||
result: 'error',
|
||||
msg: 'Rate limit exceeded',
|
||||
});
|
||||
|
||||
const result = await service.registerQueue(clientInstance);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('应该成功获取事件', async () => {
|
||||
clientInstance.queueId = 'test-queue-123';
|
||||
clientInstance.lastEventId = 10;
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 11,
|
||||
type: 'message',
|
||||
message: {
|
||||
id: 98765,
|
||||
sender_email: 'user@example.com',
|
||||
content: 'Test message from Zulip',
|
||||
stream_id: 1,
|
||||
subject: 'Test Topic',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
type: 'typing',
|
||||
sender: { user_id: 123 },
|
||||
},
|
||||
];
|
||||
|
||||
mockZulipClient.events.retrieve.mockResolvedValue({
|
||||
result: 'success',
|
||||
events: mockEvents,
|
||||
});
|
||||
|
||||
const result = await service.getEvents(clientInstance, true);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events).toEqual(mockEvents);
|
||||
expect(clientInstance.lastEventId).toBe(12); // 更新为最后一个事件的ID
|
||||
});
|
||||
|
||||
it('应该处理空事件队列', async () => {
|
||||
clientInstance.queueId = 'test-queue-123';
|
||||
|
||||
mockZulipClient.events.retrieve.mockResolvedValue({
|
||||
result: 'success',
|
||||
events: [],
|
||||
});
|
||||
|
||||
const result = await service.getEvents(clientInstance, true);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该成功注销事件队列', async () => {
|
||||
clientInstance.queueId = 'test-queue-123';
|
||||
|
||||
mockZulipClient.queues.deregister.mockResolvedValue({
|
||||
result: 'success',
|
||||
});
|
||||
|
||||
const result = await service.deregisterQueue(clientInstance);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(clientInstance.queueId).toBeUndefined();
|
||||
expect(clientInstance.lastEventId).toBe(-1);
|
||||
});
|
||||
|
||||
it('应该处理队列过期情况', async () => {
|
||||
clientInstance.queueId = 'expired-queue';
|
||||
|
||||
// 模拟队列过期的JSON解析错误
|
||||
mockZulipClient.queues.deregister.mockRejectedValue(
|
||||
new Error('invalid json response body at https://zulip.example.com/api/v1/events reason: Unexpected token')
|
||||
);
|
||||
|
||||
const result = await service.deregisterQueue(clientInstance);
|
||||
|
||||
expect(result).toBe(true); // 应该返回true,因为队列已过期
|
||||
expect(clientInstance.queueId).toBeUndefined();
|
||||
expect(clientInstance.lastEventId).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key验证', () => {
|
||||
it('应该成功验证有效的API Key', async () => {
|
||||
mockZulipClient.users.me.getProfile.mockResolvedValue({
|
||||
result: 'success',
|
||||
email: 'test-bot@example.com',
|
||||
full_name: 'Test Bot',
|
||||
user_id: 123,
|
||||
});
|
||||
|
||||
const isValid = await service.validateApiKey(clientInstance);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
expect(clientInstance.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝无效的API Key', async () => {
|
||||
mockZulipClient.users.me.getProfile.mockResolvedValue({
|
||||
result: 'error',
|
||||
msg: 'Invalid API key',
|
||||
});
|
||||
|
||||
const isValid = await service.validateApiKey(clientInstance);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
expect(clientInstance.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理API Key验证网络异常', async () => {
|
||||
mockZulipClient.users.me.getProfile.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const isValid = await service.validateApiKey(clientInstance);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
expect(clientInstance.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误恢复和重试机制', () => {
|
||||
it('应该在临时网络错误后恢复', async () => {
|
||||
// 第一次调用失败,第二次成功
|
||||
mockZulipClient.messages.send
|
||||
.mockRejectedValueOnce(new Error('Temporary network error'))
|
||||
.mockResolvedValueOnce({
|
||||
result: 'success',
|
||||
id: 99999,
|
||||
});
|
||||
|
||||
// 第一次调用应该失败
|
||||
const firstResult = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'First attempt'
|
||||
);
|
||||
expect(firstResult.success).toBe(false);
|
||||
|
||||
// 第二次调用应该成功
|
||||
const secondResult = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'Second attempt'
|
||||
);
|
||||
expect(secondResult.success).toBe(true);
|
||||
expect(secondResult.messageId).toBe(99999);
|
||||
});
|
||||
|
||||
it('应该处理服务器5xx错误', async () => {
|
||||
mockZulipClient.messages.send.mockRejectedValue(new Error('Internal Server Error (500)'));
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'Server error test'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Internal Server Error (500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能和并发测试', () => {
|
||||
it('应该处理并发消息发送', async () => {
|
||||
// 模拟多个并发消息
|
||||
const messagePromises = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mockZulipClient.messages.send.mockResolvedValue({
|
||||
result: 'success',
|
||||
id: 1000 + i,
|
||||
});
|
||||
|
||||
messagePromises.push(
|
||||
service.sendMessage(
|
||||
clientInstance,
|
||||
'test-stream',
|
||||
'concurrent-topic',
|
||||
`Concurrent message ${i}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(messagePromises);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe(1000 + index);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在大量消息发送时保持性能', async () => {
|
||||
const startTime = Date.now();
|
||||
const messageCount = 100;
|
||||
|
||||
mockZulipClient.messages.send.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
result: 'success',
|
||||
id: Math.floor(Math.random() * 100000),
|
||||
})
|
||||
);
|
||||
|
||||
const promises = Array.from({ length: messageCount }, (_, i) =>
|
||||
service.sendMessage(
|
||||
clientInstance,
|
||||
'performance-stream',
|
||||
'performance-topic',
|
||||
`Performance test message ${i}`
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 验证所有消息都成功发送
|
||||
results.forEach(result => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// 性能检查:100条消息应该在合理时间内完成(这里设为5秒)
|
||||
expect(duration).toBeLessThan(5000);
|
||||
|
||||
console.log(`发送${messageCount}条消息耗时: ${duration}ms`);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,14 @@
|
||||
* - 内部类型层:定义系统内部使用的数据类型
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-10: 代码质量优化 - 清理未使用的接口定义 (修改者: moyin)
|
||||
* - 2026-01-08: 文件夹扁平化 - 从interfaces/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新import路径和注释规范
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @version 1.0.3
|
||||
* @since 2025-12-25
|
||||
* @lastModified 2026-01-08
|
||||
* @lastModified 2026-01-10
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -356,23 +357,6 @@ export namespace Internal {
|
||||
* 服务请求接口
|
||||
*/
|
||||
export namespace ServiceRequests {
|
||||
/**
|
||||
* 玩家登录请求
|
||||
*/
|
||||
export interface PlayerLoginRequest {
|
||||
token: string;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息请求
|
||||
*/
|
||||
export interface ChatMessageRequest {
|
||||
socketId: string;
|
||||
content: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新请求
|
||||
*/
|
||||
@@ -395,20 +379,6 @@ export namespace ServiceResponses {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录响应
|
||||
*/
|
||||
export interface LoginResponse extends BaseResponse {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息响应
|
||||
*/
|
||||
export interface ChatMessageResponse extends BaseResponse {
|
||||
messageId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,4 +48,31 @@ export const TEST_WAIT_TIME_MS = 50; // 测试等待时间(毫秒)
|
||||
|
||||
// 错误率阈值
|
||||
export const ERROR_RATE_THRESHOLD = 0.1; // 错误率阈值(10%)
|
||||
export const MEMORY_THRESHOLD = 0.9; // 内存使用阈值(90%)
|
||||
export const MEMORY_THRESHOLD = 0.9; // 内存使用阈值(90%)
|
||||
|
||||
// 时间转换常量
|
||||
export const MILLISECONDS_PER_SECOND = 1000; // 毫秒转秒
|
||||
export const SECONDS_PER_MINUTE = 60; // 秒转分钟
|
||||
export const MILLISECONDS_PER_MINUTE = SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND; // 毫秒转分钟
|
||||
|
||||
// 密码生成常量
|
||||
export const DEFAULT_PASSWORD_LENGTH = 12; // 默认密码长度
|
||||
|
||||
// 监控相关常量
|
||||
export const DEFAULT_RECENT_ALERTS_LIMIT = 10; // 默认近期告警数量限制
|
||||
export const MAX_RECENT_LOGS_LIMIT = 100; // 最大近期日志数量限制
|
||||
|
||||
// 重试相关常量
|
||||
export const DEFAULT_RETRY_BASE_DELAY_MS = 1000; // 默认重试基础延迟(毫秒)
|
||||
export const DEFAULT_RETRY_MAX_DELAY_MS = 30000; // 默认重试最大延迟(毫秒)
|
||||
export const DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2; // 默认重试退避倍数
|
||||
|
||||
// HTTP状态码常量
|
||||
export const HTTP_STATUS_UNAUTHORIZED = 401; // 未授权
|
||||
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; // 请求过多
|
||||
export const HTTP_STATUS_CLIENT_ERROR_MIN = 400; // 客户端错误最小值
|
||||
export const HTTP_STATUS_CLIENT_ERROR_MAX = 500; // 客户端错误最大值
|
||||
|
||||
// 错误重试延迟常量
|
||||
export const CONNECTION_ERROR_RETRY_DELAY_MS = 5000; // 连接错误重试延迟(毫秒)
|
||||
export const RATE_LIMIT_ERROR_RETRY_DELAY_MS = 60000; // 限流错误重试延迟(毫秒)
|
||||
48
test/zulip_integration/README.md
Normal file
48
test/zulip_integration/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Zulip集成测试
|
||||
|
||||
## 测试结构
|
||||
|
||||
### 单元测试 (unit/)
|
||||
- `zulip_client.service.spec.ts` - ZulipClientService单元测试
|
||||
- `zulip_client_pool.service.spec.ts` - ZulipClientPoolService单元测试
|
||||
- `zulip.service.spec.ts` - ZulipService单元测试
|
||||
|
||||
### 集成测试 (integration/)
|
||||
- `real_zulip_api.spec.ts` - 真实Zulip API集成测试
|
||||
- `chat_message_integration.spec.ts` - 聊天消息集成测试
|
||||
|
||||
### 端到端测试 (e2e/)
|
||||
- `chat_message_e2e.spec.ts` - 完整聊天流程端到端测试
|
||||
|
||||
### 性能测试 (performance/)
|
||||
- `optimized_chat_performance.spec.ts` - 优化架构性能测试
|
||||
- `load_test.spec.ts` - 负载测试
|
||||
|
||||
### 工具脚本 (tools/)
|
||||
- `simple_connection_test.ts` - 简单连接测试工具
|
||||
- `list_streams.ts` - Stream列表查询工具
|
||||
- `chat_simulation.ts` - 聊天模拟工具
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm run test:zulip
|
||||
|
||||
# 运行单元测试
|
||||
npm run test:zulip:unit
|
||||
|
||||
# 运行集成测试(需要真实Zulip配置)
|
||||
npm run test:zulip:integration
|
||||
|
||||
# 运行性能测试
|
||||
npm run test:zulip:performance
|
||||
```
|
||||
|
||||
## 配置要求
|
||||
|
||||
集成测试需要以下环境变量:
|
||||
- `ZULIP_SERVER_URL` - Zulip服务器地址
|
||||
- `ZULIP_BOT_EMAIL` - Bot邮箱
|
||||
- `ZULIP_BOT_API_KEY` - Bot API Key
|
||||
- `ZULIP_TEST_STREAM` - 测试Stream名称
|
||||
448
test/zulip_integration/chat_message_e2e.spec.ts
Normal file
448
test/zulip_integration/chat_message_e2e.spec.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* 聊天消息端到端集成测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试从WebSocket接收消息到Zulip服务器发送的完整流程
|
||||
* - 验证消息路由、过滤、认证等中间环节
|
||||
* - 测试真实的网络请求和响应处理
|
||||
*
|
||||
* 测试范围:
|
||||
* - WebSocket → ZulipService → ZulipClientPool → ZulipClient → Zulip API
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ZulipService } from '../../src/business/zulip/zulip.service';
|
||||
import { ZulipClientPoolService } from '../../src/core/zulip_core/services/zulip_client_pool.service';
|
||||
import { ZulipClientService, ZulipClientInstance } from '../../src/core/zulip_core/services/zulip_client.service';
|
||||
import { SessionManagerService } from '../../src/business/zulip/services/session_manager.service';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
|
||||
describe('ChatMessage E2E Integration', () => {
|
||||
let app: INestApplication;
|
||||
let zulipService: ZulipService;
|
||||
let zulipClientPool: ZulipClientPoolService;
|
||||
let zulipClient: ZulipClientService;
|
||||
let sessionManager: SessionManagerService;
|
||||
|
||||
// 模拟的Zulip客户端
|
||||
let mockZulipSdkClient: any;
|
||||
|
||||
// 测试数据
|
||||
const testUserId = 'test-user-12345';
|
||||
const testSocketId = 'ws_test_socket_123';
|
||||
const testConfig = {
|
||||
username: 'test-bot@example.com',
|
||||
apiKey: 'test-api-key-abcdef',
|
||||
realm: 'https://test-zulip.example.com',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
// 获取服务实例
|
||||
zulipService = moduleFixture.get<ZulipService>(ZulipService);
|
||||
zulipClientPool = moduleFixture.get<ZulipClientPoolService>(ZulipClientPoolService);
|
||||
zulipClient = moduleFixture.get<ZulipClientService>(ZulipClientService);
|
||||
sessionManager = moduleFixture.get<SessionManagerService>(SessionManagerService);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// 创建模拟的zulip-js客户端
|
||||
mockZulipSdkClient = {
|
||||
config: testConfig,
|
||||
users: {
|
||||
me: {
|
||||
getProfile: jest.fn().mockResolvedValue({
|
||||
result: 'success',
|
||||
email: testConfig.username,
|
||||
full_name: 'Test Bot',
|
||||
user_id: 123,
|
||||
}),
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
send: jest.fn().mockResolvedValue({
|
||||
result: 'success',
|
||||
id: 12345,
|
||||
}),
|
||||
},
|
||||
queues: {
|
||||
register: jest.fn().mockResolvedValue({
|
||||
result: 'success',
|
||||
queue_id: 'test-queue-123',
|
||||
last_event_id: 0,
|
||||
}),
|
||||
deregister: jest.fn().mockResolvedValue({
|
||||
result: 'success',
|
||||
}),
|
||||
},
|
||||
events: {
|
||||
retrieve: jest.fn().mockResolvedValue({
|
||||
result: 'success',
|
||||
events: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock zulip-js模块
|
||||
jest.spyOn(zulipClient as any, 'loadZulipModule').mockResolvedValue(() => mockZulipSdkClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('完整的聊天消息流程', () => {
|
||||
it('应该成功处理从登录到消息发送的完整流程', async () => {
|
||||
// 1. 模拟用户登录
|
||||
const loginResult = await zulipService.handlePlayerLogin({
|
||||
socketId: testSocketId,
|
||||
token: 'valid-jwt-token', // 这里需要有效的JWT token
|
||||
});
|
||||
|
||||
// 验证登录成功(可能需要根据实际JWT验证逻辑调整)
|
||||
if (loginResult.success) {
|
||||
expect(loginResult.userId).toBeDefined();
|
||||
expect(loginResult.sessionId).toBeDefined();
|
||||
|
||||
// 2. 发送聊天消息
|
||||
const chatResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'Hello from E2E test!',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
// 验证消息发送成功
|
||||
expect(chatResult.success).toBe(true);
|
||||
expect(chatResult.messageId).toBeDefined();
|
||||
|
||||
// 验证Zulip API被正确调用
|
||||
expect(mockZulipSdkClient.messages.send).toHaveBeenCalledWith({
|
||||
type: 'stream',
|
||||
to: expect.any(String), // Stream名称
|
||||
subject: expect.any(String), // Topic名称
|
||||
content: 'Hello from E2E test!',
|
||||
});
|
||||
} else {
|
||||
// 如果登录失败,跳过测试或使用模拟会话
|
||||
console.warn('登录失败,使用模拟会话进行测试');
|
||||
|
||||
// 创建模拟会话
|
||||
await sessionManager.createSession(
|
||||
testSocketId,
|
||||
testUserId,
|
||||
'test-queue-123',
|
||||
'TestUser',
|
||||
'whale_port',
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
// 发送消息
|
||||
const chatResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'Hello from E2E test with mock session!',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
expect(chatResult.success).toBe(true);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it('应该正确处理不同消息范围的路由', async () => {
|
||||
// 创建测试会话
|
||||
await sessionManager.createSession(
|
||||
testSocketId,
|
||||
testUserId,
|
||||
'test-queue-123',
|
||||
'TestUser',
|
||||
'whale_port',
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
// 测试本地消息
|
||||
const localResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'Local message test',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
expect(localResult.success).toBe(true);
|
||||
|
||||
// 验证消息被发送到正确的Stream
|
||||
const localCall = mockZulipSdkClient.messages.send.mock.calls.find(
|
||||
(call: any) => call[0].content === 'Local message test'
|
||||
);
|
||||
expect(localCall).toBeDefined();
|
||||
expect(localCall[0].to).toBe('Whale Port'); // 应该路由到地图对应的Stream
|
||||
|
||||
// 测试全局消息
|
||||
const globalResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'Global message test',
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
expect(globalResult.success).toBe(true);
|
||||
|
||||
// 验证全局消息路由
|
||||
const globalCall = mockZulipSdkClient.messages.send.mock.calls.find(
|
||||
(call: any) => call[0].content === 'Global message test'
|
||||
);
|
||||
expect(globalCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理消息过滤和验证', async () => {
|
||||
// 创建测试会话
|
||||
await sessionManager.createSession(
|
||||
testSocketId,
|
||||
testUserId,
|
||||
'test-queue-123',
|
||||
'TestUser',
|
||||
'whale_port',
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
// 测试正常消息
|
||||
const normalResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'This is a normal message',
|
||||
scope: 'local',
|
||||
});
|
||||
expect(normalResult.success).toBe(true);
|
||||
|
||||
// 测试空消息
|
||||
const emptyResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: '',
|
||||
scope: 'local',
|
||||
});
|
||||
expect(emptyResult.success).toBe(false);
|
||||
|
||||
// 测试过长消息
|
||||
const longMessage = 'A'.repeat(2000); // 假设限制是1000字符
|
||||
const longResult = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: longMessage,
|
||||
scope: 'local',
|
||||
});
|
||||
// 根据实际过滤规则验证结果
|
||||
console.log('Long message result:', longResult);
|
||||
});
|
||||
|
||||
it('应该处理Zulip API错误', async () => {
|
||||
// 创建测试会话
|
||||
await sessionManager.createSession(
|
||||
testSocketId,
|
||||
testUserId,
|
||||
'test-queue-123',
|
||||
'TestUser',
|
||||
'whale_port',
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
// 模拟Zulip API错误
|
||||
mockZulipSdkClient.messages.send.mockResolvedValueOnce({
|
||||
result: 'error',
|
||||
msg: 'Stream does not exist',
|
||||
code: 'STREAM_NOT_FOUND',
|
||||
});
|
||||
|
||||
const result = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'This message will fail',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
// 验证错误处理(根据实际业务逻辑,可能返回成功但记录错误)
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理网络异常', async () => {
|
||||
// 创建测试会话
|
||||
await sessionManager.createSession(
|
||||
testSocketId,
|
||||
testUserId,
|
||||
'test-queue-123',
|
||||
'TestUser',
|
||||
'whale_port',
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
// 模拟网络异常
|
||||
mockZulipSdkClient.messages.send.mockRejectedValueOnce(new Error('Network timeout'));
|
||||
|
||||
const result = await zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: 'This will timeout',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
// 验证网络异常处理
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('客户端池管理', () => {
|
||||
it('应该正确管理用户的Zulip客户端', async () => {
|
||||
// 创建用户客户端
|
||||
const clientInstance = await zulipClientPool.createUserClient(testUserId, testConfig);
|
||||
|
||||
expect(clientInstance).toBeDefined();
|
||||
expect(clientInstance.userId).toBe(testUserId);
|
||||
expect(clientInstance.isValid).toBe(true);
|
||||
|
||||
// 验证客户端可以发送消息
|
||||
const sendResult = await zulipClientPool.sendMessage(
|
||||
testUserId,
|
||||
'test-stream',
|
||||
'test-topic',
|
||||
'Test message from pool'
|
||||
);
|
||||
|
||||
expect(sendResult.success).toBe(true);
|
||||
expect(mockZulipSdkClient.messages.send).toHaveBeenCalledWith({
|
||||
type: 'stream',
|
||||
to: 'test-stream',
|
||||
subject: 'test-topic',
|
||||
content: 'Test message from pool',
|
||||
});
|
||||
|
||||
// 清理客户端
|
||||
await zulipClientPool.destroyUserClient(testUserId);
|
||||
});
|
||||
|
||||
it('应该处理多用户并发', async () => {
|
||||
const userIds = ['user1', 'user2', 'user3'];
|
||||
const clients: ZulipClientInstance[] = [];
|
||||
|
||||
// 创建多个用户客户端
|
||||
for (const userId of userIds) {
|
||||
const client = await zulipClientPool.createUserClient(userId, {
|
||||
...testConfig,
|
||||
username: `${userId}@example.com`,
|
||||
});
|
||||
clients.push(client);
|
||||
}
|
||||
|
||||
// 并发发送消息
|
||||
const sendPromises = userIds.map(userId =>
|
||||
zulipClientPool.sendMessage(
|
||||
userId,
|
||||
'concurrent-stream',
|
||||
'concurrent-topic',
|
||||
`Message from ${userId}`
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(sendPromises);
|
||||
|
||||
// 验证所有消息都成功发送
|
||||
results.forEach(result => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// 清理所有客户端
|
||||
for (const userId of userIds) {
|
||||
await zulipClientPool.destroyUserClient(userId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('事件队列集成', () => {
|
||||
it('应该正确处理事件队列生命周期', async () => {
|
||||
// 创建客户端
|
||||
const clientInstance = await zulipClientPool.createUserClient(testUserId, testConfig);
|
||||
|
||||
// 验证队列已注册
|
||||
expect(clientInstance.queueId).toBeDefined();
|
||||
expect(mockZulipSdkClient.queues.register).toHaveBeenCalled();
|
||||
|
||||
// 模拟接收事件
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'message',
|
||||
message: {
|
||||
id: 98765,
|
||||
sender_email: 'other-user@example.com',
|
||||
content: 'Hello from other user',
|
||||
stream_id: 1,
|
||||
subject: 'Test Topic',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockZulipSdkClient.events.retrieve.mockResolvedValueOnce({
|
||||
result: 'success',
|
||||
events: mockEvents,
|
||||
});
|
||||
|
||||
// 获取事件
|
||||
const userClient: ZulipClientInstance | null = await zulipClientPool.getUserClient(testUserId);
|
||||
if (userClient) {
|
||||
const eventsResult = await zulipClient.getEvents(userClient, true);
|
||||
expect(eventsResult.success).toBe(true);
|
||||
expect(eventsResult.events).toEqual(mockEvents);
|
||||
}
|
||||
|
||||
// 清理
|
||||
await zulipClientPool.destroyUserClient(testUserId);
|
||||
expect(mockZulipSdkClient.queues.deregister).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能测试', () => {
|
||||
it('应该在高负载下保持性能', async () => {
|
||||
const messageCount = 50;
|
||||
const startTime = Date.now();
|
||||
|
||||
// 创建测试会话
|
||||
await sessionManager.createSession(
|
||||
testSocketId,
|
||||
testUserId,
|
||||
'test-queue-123',
|
||||
'TestUser',
|
||||
'whale_port',
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
|
||||
// 发送大量消息
|
||||
const promises = Array.from({ length: messageCount }, (_, i) =>
|
||||
zulipService.sendChatMessage({
|
||||
socketId: testSocketId,
|
||||
content: `Performance test message ${i}`,
|
||||
scope: 'local',
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 验证所有消息处理完成
|
||||
expect(results).toHaveLength(messageCount);
|
||||
|
||||
// 性能检查
|
||||
const avgTimePerMessage = duration / messageCount;
|
||||
console.log(`处理${messageCount}条消息耗时: ${duration}ms, 平均每条: ${avgTimePerMessage.toFixed(2)}ms`);
|
||||
|
||||
// 期望平均每条消息处理时间不超过100ms
|
||||
expect(avgTimePerMessage).toBeLessThan(100);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
358
test/zulip_integration/performance/chat_performance.spec.ts
Normal file
358
test/zulip_integration/performance/chat_performance.spec.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Zulip聊天性能测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试优化后聊天架构的性能表现
|
||||
* - 验证游戏内实时广播 + Zulip异步同步的效果
|
||||
* - 测试高并发场景下的系统稳定性
|
||||
*
|
||||
* 测试场景:
|
||||
* - 单用户消息发送性能
|
||||
* - 多用户并发聊天性能
|
||||
* - 大量消息批量处理性能
|
||||
* - 内存使用和资源清理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ZulipService } from '../../../src/business/zulip/zulip.service';
|
||||
import { ZulipClientPoolService } from '../../../src/core/zulip_core/services/zulip_client_pool.service';
|
||||
import { SessionManagerService } from '../../../src/business/zulip/services/session_manager.service';
|
||||
import { MessageFilterService } from '../../../src/business/zulip/services/message_filter.service';
|
||||
|
||||
// 模拟WebSocket网关
|
||||
class MockWebSocketGateway {
|
||||
private sentMessages: Array<{ socketId: string; data: any }> = [];
|
||||
private broadcastMessages: Array<{ mapId: string; data: any }> = [];
|
||||
|
||||
sendToPlayer(socketId: string, data: any): void {
|
||||
this.sentMessages.push({ socketId, data });
|
||||
}
|
||||
|
||||
broadcastToMap(mapId: string, data: any, excludeId?: string): void {
|
||||
this.broadcastMessages.push({ mapId, data });
|
||||
}
|
||||
|
||||
getSentMessages() { return this.sentMessages; }
|
||||
getBroadcastMessages() { return this.broadcastMessages; }
|
||||
clearMessages() {
|
||||
this.sentMessages = [];
|
||||
this.broadcastMessages = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe('Zulip聊天性能测试', () => {
|
||||
let zulipService: ZulipService;
|
||||
let sessionManager: SessionManagerService;
|
||||
let mockWebSocketGateway: MockWebSocketGateway;
|
||||
let mockZulipClientPool: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
// 创建模拟服务
|
||||
mockZulipClientPool = {
|
||||
sendMessage: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
messageId: 'zulip-msg-123',
|
||||
}),
|
||||
createUserClient: jest.fn(),
|
||||
destroyUserClient: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSessionManager = {
|
||||
getSession: jest.fn().mockResolvedValue({
|
||||
sessionId: 'test-session',
|
||||
userId: 'user-123',
|
||||
username: 'TestPlayer',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 100, y: 200 },
|
||||
}),
|
||||
injectContext: jest.fn().mockResolvedValue({
|
||||
stream: 'Whale Port',
|
||||
topic: 'Town Square Chat',
|
||||
}),
|
||||
getSocketsInMap: jest.fn().mockResolvedValue(['socket-1', 'socket-2', 'socket-3']),
|
||||
createSession: jest.fn(),
|
||||
destroySession: jest.fn(),
|
||||
updatePlayerPosition: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const mockMessageFilter = {
|
||||
validateMessage: jest.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
filteredContent: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ZulipService,
|
||||
{
|
||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||||
useValue: mockZulipClientPool,
|
||||
},
|
||||
{
|
||||
provide: SessionManagerService,
|
||||
useValue: mockSessionManager,
|
||||
},
|
||||
{
|
||||
provide: MessageFilterService,
|
||||
useValue: mockMessageFilter,
|
||||
},
|
||||
{
|
||||
provide: 'API_KEY_SECURITY_SERVICE',
|
||||
useValue: {
|
||||
getApiKey: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
apiKey: 'test-api-key',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'LoginCoreService',
|
||||
useValue: {
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
sub: 'user-123',
|
||||
username: 'TestPlayer',
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
zulipService = module.get<ZulipService>(ZulipService);
|
||||
sessionManager = module.get<SessionManagerService>(SessionManagerService);
|
||||
|
||||
// 设置WebSocket网关
|
||||
mockWebSocketGateway = new MockWebSocketGateway();
|
||||
zulipService.setWebSocketGateway(mockWebSocketGateway as any);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWebSocketGateway.clearMessages();
|
||||
});
|
||||
|
||||
describe('单用户消息发送性能', () => {
|
||||
it('应该在50ms内完成游戏内广播', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const result = await zulipService.sendChatMessage({
|
||||
socketId: 'test-socket',
|
||||
content: 'Performance test message',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(duration).toBeLessThan(50); // 游戏内广播应该在50ms内完成
|
||||
|
||||
console.log(`游戏内广播耗时: ${duration}ms`);
|
||||
});
|
||||
|
||||
it('应该异步处理Zulip同步,不阻塞游戏聊天', async () => {
|
||||
// 模拟Zulip同步延迟
|
||||
mockZulipClientPool.sendMessage.mockImplementation(() =>
|
||||
new Promise(resolve => setTimeout(() => resolve({
|
||||
success: true,
|
||||
messageId: 'delayed-msg',
|
||||
}), 200))
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const result = await zulipService.sendChatMessage({
|
||||
socketId: 'test-socket',
|
||||
content: 'Async test message',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(duration).toBeLessThan(100); // 不应该等待Zulip同步完成
|
||||
|
||||
console.log(`异步处理耗时: ${duration}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('多用户并发聊天性能', () => {
|
||||
it('应该处理50个并发消息', async () => {
|
||||
const messageCount = 50;
|
||||
const startTime = Date.now();
|
||||
|
||||
const promises = Array.from({ length: messageCount }, (_, i) =>
|
||||
zulipService.sendChatMessage({
|
||||
socketId: `socket-${i}`,
|
||||
content: `Concurrent message ${i}`,
|
||||
scope: 'local',
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 验证所有消息都成功处理
|
||||
expect(results).toHaveLength(messageCount);
|
||||
results.forEach(result => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
const avgTimePerMessage = duration / messageCount;
|
||||
console.log(`处理${messageCount}条并发消息耗时: ${duration}ms, 平均每条: ${avgTimePerMessage.toFixed(2)}ms`);
|
||||
|
||||
// 期望平均每条消息处理时间不超过20ms
|
||||
expect(avgTimePerMessage).toBeLessThan(20);
|
||||
}, 10000);
|
||||
|
||||
it('应该正确广播给地图内的所有玩家', async () => {
|
||||
await zulipService.sendChatMessage({
|
||||
socketId: 'sender-socket',
|
||||
content: 'Broadcast test message',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
// 验证广播消息
|
||||
const broadcastMessages = mockWebSocketGateway.getBroadcastMessages();
|
||||
expect(broadcastMessages).toHaveLength(1);
|
||||
|
||||
const broadcastMessage = broadcastMessages[0];
|
||||
expect(broadcastMessage.mapId).toBe('whale_port');
|
||||
expect(broadcastMessage.data.t).toBe('chat_render');
|
||||
expect(broadcastMessage.data.txt).toBe('Broadcast test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('批量消息处理性能', () => {
|
||||
it('应该高效处理大量消息', async () => {
|
||||
const batchSize = 100;
|
||||
const startTime = Date.now();
|
||||
|
||||
// 创建批量消息
|
||||
const batchPromises = Array.from({ length: batchSize }, (_, i) =>
|
||||
zulipService.sendChatMessage({
|
||||
socketId: 'batch-socket',
|
||||
content: `Batch message ${i}`,
|
||||
scope: 'local',
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(batchPromises);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 验证处理结果
|
||||
expect(results).toHaveLength(batchSize);
|
||||
results.forEach((result, index) => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
});
|
||||
|
||||
const throughput = (batchSize / duration) * 1000; // 每秒处理的消息数
|
||||
console.log(`批量处理${batchSize}条消息耗时: ${duration}ms, 吞吐量: ${throughput.toFixed(2)} msg/s`);
|
||||
|
||||
// 期望吞吐量至少达到500 msg/s
|
||||
expect(throughput).toBeGreaterThan(500);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('内存使用和资源清理', () => {
|
||||
it('应该正确清理会话资源', async () => {
|
||||
// 创建多个会话
|
||||
const sessionCount = 10;
|
||||
const sessionIds = Array.from({ length: sessionCount }, (_, i) => `session-${i}`);
|
||||
|
||||
// 模拟会话创建
|
||||
for (const sessionId of sessionIds) {
|
||||
await zulipService.handlePlayerLogin({
|
||||
socketId: sessionId,
|
||||
token: 'valid-jwt-token',
|
||||
});
|
||||
}
|
||||
|
||||
// 清理所有会话
|
||||
for (const sessionId of sessionIds) {
|
||||
await zulipService.handlePlayerLogout(sessionId);
|
||||
}
|
||||
|
||||
// 验证资源清理
|
||||
expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledTimes(sessionCount);
|
||||
});
|
||||
|
||||
it('应该处理内存压力测试', async () => {
|
||||
const initialMemory = process.memoryUsage();
|
||||
|
||||
// 创建大量临时对象
|
||||
const largeDataSet = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
data: 'x'.repeat(1000), // 1KB per object
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
|
||||
// 处理大量消息
|
||||
const promises = largeDataSet.map((item, i) =>
|
||||
zulipService.sendChatMessage({
|
||||
socketId: `memory-test-${i}`,
|
||||
content: `Memory test ${item.id}: ${item.data.substring(0, 50)}...`,
|
||||
scope: 'local',
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// 强制垃圾回收(如果可用)
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
const finalMemory = process.memoryUsage();
|
||||
const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed;
|
||||
|
||||
console.log(`内存使用增加: ${(memoryIncrease / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
// 期望内存增加不超过50MB
|
||||
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe('错误处理性能', () => {
|
||||
it('应该快速处理无效会话', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const result = await zulipService.sendChatMessage({
|
||||
socketId: 'invalid-socket',
|
||||
content: 'This should fail quickly',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('会话不存在');
|
||||
expect(duration).toBeLessThan(10); // 错误处理应该很快
|
||||
|
||||
console.log(`错误处理耗时: ${duration}ms`);
|
||||
});
|
||||
|
||||
it('应该处理Zulip服务异常而不影响游戏聊天', async () => {
|
||||
// 模拟Zulip服务异常
|
||||
mockZulipClientPool.sendMessage.mockRejectedValue(new Error('Zulip service unavailable'));
|
||||
|
||||
const result = await zulipService.sendChatMessage({
|
||||
socketId: 'test-socket',
|
||||
content: 'Message during Zulip outage',
|
||||
scope: 'local',
|
||||
});
|
||||
|
||||
// 游戏内聊天应该仍然成功
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// 验证游戏内广播仍然工作
|
||||
const broadcastMessages = mockWebSocketGateway.getBroadcastMessages();
|
||||
expect(broadcastMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
393
test/zulip_integration/real_zulip_api.spec.ts
Normal file
393
test/zulip_integration/real_zulip_api.spec.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 真实Zulip API测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试与真实Zulip服务器的HTTP通信
|
||||
* - 验证API请求格式、认证和响应处理
|
||||
* - 需要真实的Zulip服务器配置才能运行
|
||||
*
|
||||
* 注意:
|
||||
* - 这些测试需要设置环境变量:ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY
|
||||
* - 如果没有配置,测试将被跳过
|
||||
* - 测试会在真实服务器上创建消息,请谨慎使用
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ZulipClientService, ZulipClientConfig, SendMessageResult } from '../../src/core/zulip_core/services/zulip_client.service';
|
||||
|
||||
// 测试配置
|
||||
const REAL_ZULIP_CONFIG = {
|
||||
serverUrl: process.env.ZULIP_SERVER_URL || '',
|
||||
botEmail: process.env.ZULIP_BOT_EMAIL || '',
|
||||
botApiKey: process.env.ZULIP_BOT_API_KEY || '',
|
||||
testStream: process.env.ZULIP_TEST_STREAM || 'test-stream',
|
||||
testTopic: process.env.ZULIP_TEST_TOPIC || 'API Test',
|
||||
};
|
||||
|
||||
// 检查是否有真实配置
|
||||
const hasRealConfig: boolean = !!(REAL_ZULIP_CONFIG.serverUrl &&
|
||||
REAL_ZULIP_CONFIG.botEmail &&
|
||||
REAL_ZULIP_CONFIG.botApiKey);
|
||||
|
||||
describe('Real Zulip API Integration', () => {
|
||||
let service: ZulipClientService;
|
||||
let clientConfig: ZulipClientConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!hasRealConfig) {
|
||||
console.warn('跳过真实Zulip API测试:缺少环境变量配置');
|
||||
console.warn('需要设置: ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY');
|
||||
return;
|
||||
}
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ZulipClientService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ZulipClientService>(ZulipClientService);
|
||||
|
||||
clientConfig = {
|
||||
username: REAL_ZULIP_CONFIG.botEmail,
|
||||
apiKey: REAL_ZULIP_CONFIG.botApiKey,
|
||||
realm: REAL_ZULIP_CONFIG.serverUrl,
|
||||
};
|
||||
});
|
||||
|
||||
// 如果没有真实配置,跳过所有测试
|
||||
const testIf = (condition: boolean) => condition ? it : it.skip;
|
||||
|
||||
describe('API连接测试', () => {
|
||||
testIf(hasRealConfig)('应该能够连接到Zulip服务器', async () => {
|
||||
const clientInstance = await service.createClient('test-user', clientConfig);
|
||||
|
||||
expect(clientInstance).toBeDefined();
|
||||
expect(clientInstance.isValid).toBe(true);
|
||||
expect(clientInstance.config.realm).toBe(REAL_ZULIP_CONFIG.serverUrl);
|
||||
|
||||
// 清理
|
||||
await service.destroyClient(clientInstance);
|
||||
}, 10000);
|
||||
|
||||
testIf(hasRealConfig)('应该能够验证API Key', async () => {
|
||||
const clientInstance = await service.createClient('test-user', clientConfig);
|
||||
|
||||
const isValid = await service.validateApiKey(clientInstance);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
await service.destroyClient(clientInstance);
|
||||
}, 10000);
|
||||
|
||||
testIf(hasRealConfig)('应该拒绝无效的API Key', async () => {
|
||||
const invalidConfig = {
|
||||
...clientConfig,
|
||||
apiKey: 'invalid-api-key-12345',
|
||||
};
|
||||
|
||||
await expect(service.createClient('test-user', invalidConfig))
|
||||
.rejects.toThrow();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('消息发送测试', () => {
|
||||
let clientInstance: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!hasRealConfig) return;
|
||||
|
||||
clientInstance = await service.createClient('test-user', clientConfig);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (clientInstance) {
|
||||
await service.destroyClient(clientInstance);
|
||||
}
|
||||
});
|
||||
|
||||
testIf(hasRealConfig)('应该能够发送消息到Zulip', async () => {
|
||||
const testMessage = `Test message from automated test - ${new Date().toISOString()}`;
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
REAL_ZULIP_CONFIG.testStream,
|
||||
REAL_ZULIP_CONFIG.testTopic,
|
||||
testMessage
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
expect(typeof result.messageId).toBe('number');
|
||||
|
||||
console.log(`消息发送成功,ID: ${result.messageId}`);
|
||||
}, 15000);
|
||||
|
||||
testIf(hasRealConfig)('应该处理不存在的Stream', async () => {
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
'nonexistent-stream-12345',
|
||||
'test-topic',
|
||||
'This should fail'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
console.log(`预期的错误: ${result.error}`);
|
||||
}, 10000);
|
||||
|
||||
testIf(hasRealConfig)('应该能够发送包含特殊字符的消息', async () => {
|
||||
const specialMessage = `特殊字符测试 🎮🎯🚀 @#$%^&*() - ${new Date().toISOString()}`;
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
REAL_ZULIP_CONFIG.testStream,
|
||||
REAL_ZULIP_CONFIG.testTopic,
|
||||
specialMessage
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
}, 10000);
|
||||
|
||||
testIf(hasRealConfig)('应该能够发送Markdown格式的消息', async () => {
|
||||
const markdownMessage = `
|
||||
# Markdown测试消息
|
||||
|
||||
**粗体文本** 和 *斜体文本*
|
||||
|
||||
- 列表项 1
|
||||
- 列表项 2
|
||||
|
||||
\`代码块\`
|
||||
|
||||
> 引用文本
|
||||
|
||||
[链接](https://example.com)
|
||||
|
||||
时间戳: ${new Date().toISOString()}
|
||||
`.trim();
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
REAL_ZULIP_CONFIG.testStream,
|
||||
REAL_ZULIP_CONFIG.testTopic,
|
||||
markdownMessage
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('事件队列测试', () => {
|
||||
let clientInstance: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!hasRealConfig) return;
|
||||
|
||||
clientInstance = await service.createClient('test-user', clientConfig);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (clientInstance) {
|
||||
await service.destroyClient(clientInstance);
|
||||
}
|
||||
});
|
||||
|
||||
testIf(hasRealConfig)('应该能够注册事件队列', async () => {
|
||||
const result = await service.registerQueue(clientInstance, ['message']);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.queueId).toBeDefined();
|
||||
expect(result.lastEventId).toBeDefined();
|
||||
expect(typeof result.lastEventId).toBe('number');
|
||||
|
||||
console.log(`队列注册成功,ID: ${result.queueId}, 最后事件ID: ${result.lastEventId}`);
|
||||
}, 10000);
|
||||
|
||||
testIf(hasRealConfig)('应该能够获取事件', async () => {
|
||||
// 先注册队列
|
||||
const registerResult = await service.registerQueue(clientInstance, ['message']);
|
||||
expect(registerResult.success).toBe(true);
|
||||
|
||||
// 获取事件(非阻塞模式)
|
||||
const eventsResult = await service.getEvents(clientInstance, true);
|
||||
|
||||
expect(eventsResult.success).toBe(true);
|
||||
expect(Array.isArray(eventsResult.events)).toBe(true);
|
||||
|
||||
console.log(`获取到 ${eventsResult.events?.length || 0} 个事件`);
|
||||
}, 10000);
|
||||
|
||||
testIf(hasRealConfig)('应该能够注销事件队列', async () => {
|
||||
// 先注册队列
|
||||
const registerResult = await service.registerQueue(clientInstance, ['message']);
|
||||
expect(registerResult.success).toBe(true);
|
||||
|
||||
// 注销队列
|
||||
const deregisterResult = await service.deregisterQueue(clientInstance);
|
||||
expect(deregisterResult).toBe(true);
|
||||
expect(clientInstance.queueId).toBeUndefined();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('HTTP请求详细测试', () => {
|
||||
testIf(hasRealConfig)('应该发送正确格式的HTTP请求', async () => {
|
||||
// 这个测试验证HTTP请求的具体格式
|
||||
const clientInstance = await service.createClient('test-user', clientConfig);
|
||||
|
||||
// 监听HTTP请求(这需要拦截zulip-js的请求)
|
||||
const originalSend = clientInstance.client.messages.send;
|
||||
let capturedRequest: any = null;
|
||||
|
||||
clientInstance.client.messages.send = jest.fn().mockImplementation(async (params) => {
|
||||
capturedRequest = params;
|
||||
return originalSend.call(clientInstance.client.messages, params);
|
||||
});
|
||||
|
||||
const testMessage = `HTTP格式测试 - ${new Date().toISOString()}`;
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
REAL_ZULIP_CONFIG.testStream,
|
||||
REAL_ZULIP_CONFIG.testTopic,
|
||||
testMessage
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(capturedRequest).toBeDefined();
|
||||
expect(capturedRequest.type).toBe('stream');
|
||||
expect(capturedRequest.to).toBe(REAL_ZULIP_CONFIG.testStream);
|
||||
expect(capturedRequest.subject).toBe(REAL_ZULIP_CONFIG.testTopic);
|
||||
expect(capturedRequest.content).toBe(testMessage);
|
||||
|
||||
await service.destroyClient(clientInstance);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('错误处理测试', () => {
|
||||
testIf(hasRealConfig)('应该处理网络超时', async () => {
|
||||
const clientInstance = await service.createClient('test-user', clientConfig);
|
||||
|
||||
// 模拟网络超时(通过修改客户端配置或使用无效的服务器地址)
|
||||
const timeoutConfig = {
|
||||
...clientConfig,
|
||||
realm: 'https://timeout-test.invalid-domain-12345.com',
|
||||
};
|
||||
|
||||
try {
|
||||
const timeoutClient = await service.createClient('timeout-test', timeoutConfig);
|
||||
// 如果到达这里,说明没有超时,跳过测试
|
||||
await service.destroyClient(timeoutClient);
|
||||
console.log('网络超时测试跳过:连接成功');
|
||||
} catch (error) {
|
||||
// 预期的超时错误
|
||||
expect(error).toBeDefined();
|
||||
console.log(`预期的超时错误: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
await service.destroyClient(clientInstance);
|
||||
}, 20000);
|
||||
|
||||
testIf(hasRealConfig)('应该处理认证错误', async () => {
|
||||
const invalidConfig = {
|
||||
...clientConfig,
|
||||
apiKey: 'definitely-invalid-api-key-12345',
|
||||
};
|
||||
|
||||
try {
|
||||
await service.createClient('auth-test', invalidConfig);
|
||||
fail('应该抛出认证错误');
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect((error as Error).message).toContain('API Key验证失败');
|
||||
console.log(`预期的认证错误: ${(error as Error).message}`);
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('性能测试', () => {
|
||||
testIf(hasRealConfig)('应该测量消息发送性能', async () => {
|
||||
const clientInstance = await service.createClient('perf-test', clientConfig);
|
||||
const messageCount = 10; // 减少数量以避免对服务器造成压力
|
||||
|
||||
const startTime = Date.now();
|
||||
const promises: Promise<SendMessageResult>[] = [];
|
||||
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
promises.push(
|
||||
service.sendMessage(
|
||||
clientInstance,
|
||||
REAL_ZULIP_CONFIG.testStream,
|
||||
'Performance Test',
|
||||
`Performance test message ${i} - ${new Date().toISOString()}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 验证所有消息都成功发送
|
||||
results.forEach((result, index) => {
|
||||
expect(result.success).toBe(true);
|
||||
console.log(`消息 ${index}: ID ${result.messageId}`);
|
||||
});
|
||||
|
||||
const avgTime = duration / messageCount;
|
||||
console.log(`发送${messageCount}条消息耗时: ${duration}ms, 平均: ${avgTime.toFixed(2)}ms/条`);
|
||||
|
||||
// 性能断言(根据网络情况调整)
|
||||
expect(avgTime).toBeLessThan(2000); // 平均每条消息不超过2秒
|
||||
|
||||
await service.destroyClient(clientInstance);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// 清理测试:在所有测试完成后清理测试数据
|
||||
describe('清理测试', () => {
|
||||
testIf(hasRealConfig)('应该发送清理完成消息', async () => {
|
||||
const clientInstance = await service.createClient('cleanup-test', clientConfig);
|
||||
|
||||
const cleanupMessage = `
|
||||
🧹 自动化测试完成 - ${new Date().toISOString()}
|
||||
|
||||
本次测试运行的消息已发送完毕。
|
||||
如果看到此消息,说明Zulip API集成测试成功完成。
|
||||
|
||||
测试包括:
|
||||
- ✅ API连接和认证
|
||||
- ✅ 消息发送和格式化
|
||||
- ✅ 事件队列管理
|
||||
- ✅ 错误处理
|
||||
- ✅ 性能测试
|
||||
|
||||
所有测试消息可以安全删除。
|
||||
`.trim();
|
||||
|
||||
const result = await service.sendMessage(
|
||||
clientInstance,
|
||||
REAL_ZULIP_CONFIG.testStream,
|
||||
'Test Cleanup',
|
||||
cleanupMessage
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
console.log('清理消息发送成功');
|
||||
|
||||
await service.destroyClient(clientInstance);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
|
||||
// 导出配置检查函数,供其他测试使用
|
||||
export function hasZulipConfig(): boolean {
|
||||
return hasRealConfig;
|
||||
}
|
||||
|
||||
export function getZulipTestConfig() {
|
||||
return REAL_ZULIP_CONFIG;
|
||||
}
|
||||
284
开发者代码检查规范.md
284
开发者代码检查规范.md
@@ -294,7 +294,11 @@ async validateUser(loginRequest: LoginRequest): Promise<AuthResult> {
|
||||
**修改记录更新要求:**
|
||||
- **必须添加**:每次修改文件后,必须在"最近修改"部分添加新的修改记录
|
||||
- **信息完整**:包含修改日期、修改类型、修改内容、修改者姓名
|
||||
- **时间更新**:只有真正修改了文件内容时才更新@lastModified字段,仅检查不修改内容时不更新日期
|
||||
- **时间更新规则**:
|
||||
- **仅检查不修改**:如果只是进行代码检查而没有实际修改文件内容,不更新@lastModified字段
|
||||
- **实际修改才更新**:只有真正修改了文件内容(功能代码、注释内容、结构调整等)时才更新@lastModified字段
|
||||
- **检查规范强调**:注释规范检查本身不是修改,除非发现需要修正的问题并进行了实际修改
|
||||
- **Git变更检测**:通过git status和git diff检查文件是否有实际变更,只有git显示文件被修改时才需要添加修改记录和更新时间戳
|
||||
- **版本递增**:根据修改类型适当递增版本号
|
||||
|
||||
**版本号递增规则:**
|
||||
@@ -690,6 +694,88 @@ export class DatabaseService {
|
||||
- ❌ **Config文件**:配置文件(`.config.ts`)不需要测试文件
|
||||
- ❌ **Constants文件**:常量定义(`.constants.ts`)不需要测试文件
|
||||
|
||||
**🔥 测试代码检查严格要求(新增):**
|
||||
|
||||
#### 1. 严格一对一映射原则
|
||||
- **强制要求**:每个测试文件必须严格对应一个源文件,属于严格一对一关系
|
||||
- **禁止多对一**:不允许一个测试文件测试多个源文件的功能
|
||||
- **禁止一对多**:不允许一个源文件的测试分散在多个测试文件中
|
||||
- **命名对应**:测试文件名必须与源文件名完全对应(除.spec.ts后缀外)
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:严格一对一映射
|
||||
src/business/auth/login.service.ts
|
||||
src/business/auth/login.service.spec.ts
|
||||
|
||||
src/core/location_broadcast_core/location_broadcast_core.service.ts
|
||||
src/core/location_broadcast_core/location_broadcast_core.service.spec.ts
|
||||
|
||||
// ❌ 错误:一个测试文件测试多个源文件
|
||||
src/business/auth/auth_services.spec.ts # 测试多个service,违反一对一原则
|
||||
|
||||
// ❌ 错误:一个源文件的测试分散在多个文件
|
||||
src/business/auth/login.service.spec.ts
|
||||
src/business/auth/login_validation.spec.ts # 应该合并到login.service.spec.ts
|
||||
```
|
||||
|
||||
#### 2. 测试范围严格限制
|
||||
- **范围限制**:测试内容必须严格限于对应源文件的功能测试
|
||||
- **禁止跨文件**:不允许在单元测试中测试其他文件的功能
|
||||
- **依赖隔离**:使用Mock隔离外部依赖,专注测试当前文件
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:只测试LoginService的功能
|
||||
// 文件:src/business/auth/login.service.spec.ts
|
||||
describe('LoginService', () => {
|
||||
describe('validateUser', () => {
|
||||
it('should validate user credentials', () => {
|
||||
// 只测试LoginService.validateUser方法
|
||||
// 使用Mock隔离UserRepository等外部依赖
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ 错误:在LoginService测试中测试其他服务
|
||||
describe('LoginService', () => {
|
||||
it('should integrate with UserRepository', () => {
|
||||
// 错误:这是集成测试,应该移到test/integration/
|
||||
});
|
||||
|
||||
it('should work with EmailService', () => {
|
||||
// 错误:测试了EmailService的功能,违反范围限制
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 集成测试强制分离
|
||||
- **强制分离**:所有集成测试必须从单元测试文件中移除
|
||||
- **统一位置**:集成测试统一放在顶层`test/integration/`目录
|
||||
- **最后执行**:集成测试在所有单元测试通过后统一执行
|
||||
|
||||
#### 4. 顶层test目录结构(强制要求)
|
||||
```
|
||||
test/
|
||||
├── integration/ # 集成测试 - 测试多个模块间的交互
|
||||
│ ├── auth_integration.spec.ts
|
||||
│ ├── location_broadcast_integration.spec.ts
|
||||
│ └── zulip_integration.spec.ts
|
||||
├── e2e/ # 端到端测试 - 完整业务流程测试
|
||||
│ ├── user_registration_e2e.spec.ts
|
||||
│ ├── location_broadcast_e2e.spec.ts
|
||||
│ └── admin_operations_e2e.spec.ts
|
||||
├── performance/ # 性能测试 - WebSocket和高并发测试
|
||||
│ ├── websocket_performance.spec.ts
|
||||
│ ├── database_performance.spec.ts
|
||||
│ └── memory_usage.spec.ts
|
||||
├── property/ # 属性测试 - 基于属性的随机测试
|
||||
│ ├── admin_property.spec.ts
|
||||
│ ├── user_validation_property.spec.ts
|
||||
│ └── position_update_property.spec.ts
|
||||
└── fixtures/ # 测试数据和工具
|
||||
├── test_data.ts
|
||||
└── test_helpers.ts
|
||||
```
|
||||
|
||||
**游戏服务器特殊测试要求:**
|
||||
```typescript
|
||||
// ✅ 必须有测试的文件类型
|
||||
@@ -932,15 +1018,50 @@ describe('AdminService Properties', () => {
|
||||
|
||||
**要求:复杂Service需要集成测试文件(.integration.spec.ts)**
|
||||
|
||||
**⚠️ 重要变更:集成测试必须移动到顶层test目录**
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:游戏服务器集成测试
|
||||
// ❌ 错误:集成测试放在源文件目录(旧做法)
|
||||
src/core/location_broadcast_core/location_broadcast_core.service.ts
|
||||
src/core/location_broadcast_core/location_broadcast_core.service.spec.ts # 单元测试
|
||||
src/core/location_broadcast_core/location_broadcast_core.integration.spec.ts # 集成测试
|
||||
src/core/location_broadcast_core/location_broadcast_core.integration.spec.ts # 错误位置
|
||||
|
||||
src/business/zulip/zulip.service.ts
|
||||
src/business/zulip/zulip.service.spec.ts # 单元测试
|
||||
src/business/zulip/zulip_integration.e2e.spec.ts # E2E测试
|
||||
// ✅ 正确:集成测试统一放在顶层test目录(新要求)
|
||||
src/core/location_broadcast_core/location_broadcast_core.service.ts
|
||||
src/core/location_broadcast_core/location_broadcast_core.service.spec.ts # 单元测试
|
||||
test/integration/location_broadcast_core_integration.spec.ts # 正确位置
|
||||
|
||||
// ✅ 正确:其他类型测试的位置
|
||||
test/e2e/zulip_integration_e2e.spec.ts # E2E测试
|
||||
test/performance/websocket_performance.spec.ts # 性能测试
|
||||
test/property/admin_property.spec.ts # 属性测试
|
||||
```
|
||||
|
||||
**集成测试内容要求:**
|
||||
- **模块间交互**:测试多个模块之间的协作
|
||||
- **数据流验证**:验证数据在模块间的正确传递
|
||||
- **依赖关系**:测试真实的依赖关系而非Mock
|
||||
- **配置集成**:测试配置文件和环境变量的集成
|
||||
|
||||
**游戏服务器集成测试重点:**
|
||||
```typescript
|
||||
// test/integration/location_broadcast_integration.spec.ts
|
||||
describe('LocationBroadcast Integration', () => {
|
||||
it('should integrate gateway with core service and database', async () => {
|
||||
// 测试Gateway -> CoreService -> Database的完整链路
|
||||
});
|
||||
|
||||
it('should handle WebSocket connection with Redis session', async () => {
|
||||
// 测试WebSocket连接与Redis会话管理的集成
|
||||
});
|
||||
});
|
||||
|
||||
// test/integration/zulip_integration.spec.ts
|
||||
describe('Zulip Integration', () => {
|
||||
it('should sync messages between game chat and Zulip', async () => {
|
||||
// 测试游戏聊天与Zulip的消息同步集成
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### ⚡ 测试执行
|
||||
@@ -948,29 +1069,79 @@ src/business/zulip/zulip_integration.e2e.spec.ts # E2E测试
|
||||
**游戏服务器推荐的测试命令:**
|
||||
|
||||
```bash
|
||||
# 单元测试(排除集成测试和E2E测试)
|
||||
# 单元测试(严格限制:只执行.spec.ts文件,排除集成测试和E2E测试)
|
||||
npm run test:unit
|
||||
# 等价于: jest --testPathPattern=spec.ts --testPathIgnorePatterns="integration.spec.ts|e2e.spec.ts"
|
||||
# 等价于: jest --testPathPattern=spec.ts --testPathIgnorePatterns="integration|e2e|performance|property"
|
||||
|
||||
# 集成测试
|
||||
jest --testPathPattern=integration.spec.ts
|
||||
# 集成测试(统一在test/integration/目录执行)
|
||||
npm run test:integration
|
||||
# 等价于: jest test/integration/
|
||||
|
||||
# E2E测试(需要设置环境变量)
|
||||
# E2E测试(统一在test/e2e/目录执行,需要设置环境变量)
|
||||
npm run test:e2e
|
||||
# 等价于: cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts
|
||||
# 等价于: cross-env RUN_E2E_TESTS=true jest test/e2e/
|
||||
|
||||
# 属性测试(管理员模块)
|
||||
jest --testPathPattern=property.spec.ts
|
||||
# 属性测试(统一在test/property/目录执行)
|
||||
npm run test:property
|
||||
# 等价于: jest test/property/
|
||||
|
||||
# 性能测试(WebSocket相关)
|
||||
jest --testPathPattern=perf.spec.ts
|
||||
# 性能测试(统一在test/performance/目录执行)
|
||||
npm run test:performance
|
||||
# 等价于: jest test/performance/
|
||||
|
||||
# 全部测试
|
||||
# 分阶段执行(推荐顺序)
|
||||
npm run test:unit # 第一阶段:单元测试
|
||||
npm run test:integration # 第二阶段:集成测试
|
||||
npm run test:e2e # 第三阶段:E2E测试
|
||||
npm run test:performance # 第四阶段:性能测试
|
||||
|
||||
# 全部测试(按顺序执行所有测试)
|
||||
npm run test:all
|
||||
|
||||
# 带覆盖率的测试执行
|
||||
npm run test:cov
|
||||
```
|
||||
|
||||
**测试执行顺序说明:**
|
||||
1. **单元测试优先**:确保每个模块的基础功能正确
|
||||
2. **集成测试其次**:验证模块间的协作
|
||||
3. **E2E测试再次**:验证完整的业务流程
|
||||
4. **性能测试最后**:在功能正确的基础上验证性能
|
||||
|
||||
**Jest配置建议:**
|
||||
```javascript
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
// 单元测试配置
|
||||
testMatch: [
|
||||
'<rootDir>/src/**/*.spec.ts' // 只匹配源文件目录中的.spec.ts文件
|
||||
],
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/test/', // 忽略顶层test目录
|
||||
'integration', // 忽略集成测试
|
||||
'e2e', // 忽略E2E测试
|
||||
'performance', // 忽略性能测试
|
||||
'property' // 忽略属性测试
|
||||
],
|
||||
|
||||
// 集成测试配置(单独配置文件)
|
||||
projects: [
|
||||
{
|
||||
displayName: 'unit',
|
||||
testMatch: ['<rootDir>/src/**/*.spec.ts'],
|
||||
testPathIgnorePatterns: ['<rootDir>/test/']
|
||||
},
|
||||
{
|
||||
displayName: 'integration',
|
||||
testMatch: ['<rootDir>/test/integration/**/*.spec.ts']
|
||||
},
|
||||
{
|
||||
displayName: 'e2e',
|
||||
testMatch: ['<rootDir>/test/e2e/**/*.spec.ts']
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
---
|
||||
|
||||
## 6️⃣ 功能文档生成
|
||||
@@ -1201,6 +1372,13 @@ npm run test:cov
|
||||
|
||||
#### 测试覆盖检查清单
|
||||
- [ ] 每个Service都有对应的.spec.ts测试文件
|
||||
- [ ] 测试文件与源文件严格一对一映射
|
||||
- [ ] 测试内容严格限于对应源文件的功能范围
|
||||
- [ ] 所有集成测试已移动到test/integration/目录
|
||||
- [ ] 所有E2E测试已移动到test/e2e/目录
|
||||
- [ ] 所有性能测试已移动到test/performance/目录
|
||||
- [ ] 所有属性测试已移动到test/property/目录
|
||||
- [ ] 单元测试文件中不包含集成测试或跨文件测试代码
|
||||
- [ ] 所有公共方法都有测试覆盖
|
||||
- [ ] 测试覆盖正常情况、异常情况、边界情况
|
||||
- [ ] 测试代码质量高,真实有效
|
||||
@@ -1219,16 +1397,23 @@ npm run test:cov
|
||||
|
||||
#### 测试相关命令
|
||||
```bash
|
||||
# 游戏服务器测试命令
|
||||
npm run test:unit # 单元测试
|
||||
# 游戏服务器测试命令(更新后的结构)
|
||||
npm run test:unit # 单元测试(只测试src/目录中的.spec.ts)
|
||||
npm run test:integration # 集成测试(test/integration/目录)
|
||||
npm run test:e2e # E2E测试(test/e2e/目录)
|
||||
npm run test:property # 属性测试(test/property/目录)
|
||||
npm run test:performance # 性能测试(test/performance/目录)
|
||||
npm run test:cov # 测试覆盖率
|
||||
npm run test:e2e # E2E测试
|
||||
npm run test:all # 全部测试
|
||||
npm run test:all # 全部测试(按顺序执行)
|
||||
|
||||
# Jest特定测试类型
|
||||
jest --testPathPattern=property.spec.ts # 属性测试
|
||||
jest --testPathPattern=integration.spec.ts # 集成测试
|
||||
jest --testPathPattern=perf.spec.ts # 性能测试
|
||||
# 分阶段测试执行(推荐)
|
||||
npm run test:unit && npm run test:integration && npm run test:e2e
|
||||
|
||||
# Jest特定目录测试
|
||||
jest src/ # 只测试源文件目录
|
||||
jest test/integration/ # 只测试集成测试
|
||||
jest test/e2e/ # 只测试E2E测试
|
||||
jest test/performance/ # 只测试性能测试
|
||||
|
||||
# WebSocket测试(需要启动服务)
|
||||
npm run dev & # 后台启动开发服务器
|
||||
@@ -1278,21 +1463,36 @@ npx prettier --write src/**/*.ts
|
||||
- 解决:将业务逻辑移到Business层
|
||||
|
||||
#### 测试覆盖常见错误
|
||||
1. **WebSocket测试文件缺失**
|
||||
1. **测试文件位置错误**
|
||||
- 错误:测试文件放在单独的tests/文件夹中
|
||||
- 正确:测试文件必须与源文件放在同一目录
|
||||
- 解决:将测试文件移动到对应源文件的同一目录
|
||||
|
||||
2. **测试范围混乱**
|
||||
- 错误:单元测试中包含集成测试代码
|
||||
- 正确:严格区分单元测试和集成测试
|
||||
- 解决:将集成测试移动到test/integration/目录
|
||||
|
||||
3. **一对多测试文件**
|
||||
- 错误:一个测试文件测试多个源文件
|
||||
- 正确:每个测试文件严格对应一个源文件
|
||||
- 解决:拆分测试文件,确保一对一映射
|
||||
|
||||
4. **WebSocket测试文件缺失**
|
||||
- 错误:Gateway没有对应的.spec.ts文件
|
||||
- 解决:为每个Gateway创建完整的连接、消息处理测试
|
||||
|
||||
2. **双模式测试不完整**
|
||||
5. **双模式测试不完整**
|
||||
- 错误:只测试数据库模式,忽略内存模式
|
||||
- 正确:确保两种模式行为一致性测试
|
||||
- 解决:创建对比测试用例
|
||||
|
||||
3. **属性测试缺失**
|
||||
6. **属性测试缺失**
|
||||
- 错误:管理员模块缺少随机化测试
|
||||
- 正确:使用fast-check进行属性测试
|
||||
- 解决:补充基于属性的测试用例
|
||||
- 解决:在test/property/目录补充基于属性的测试用例
|
||||
|
||||
4. **实时通信测试场景不完整**
|
||||
7. **实时通信测试场景不完整**
|
||||
- 错误:只测试正常连接,忽略异常断开
|
||||
- 正确:测试连接、断开、重连、消息处理全流程
|
||||
- 解决:补充WebSocket生命周期测试
|
||||
@@ -1402,21 +1602,39 @@ npx prettier --write src/**/*.ts
|
||||
|
||||
### 🧪 测试策略优化
|
||||
|
||||
1. **属性测试应用**
|
||||
1. **严格一对一测试映射**
|
||||
- 每个测试文件严格对应一个源文件
|
||||
- 测试内容严格限于对应源文件的功能
|
||||
- 禁止跨文件测试和混合测试
|
||||
|
||||
2. **分层测试架构**
|
||||
- 单元测试:放在源文件同目录,测试单个模块功能
|
||||
- 集成测试:统一放在test/integration/,测试模块间协作
|
||||
- E2E测试:统一放在test/e2e/,测试完整业务流程
|
||||
- 性能测试:统一放在test/performance/,测试系统性能
|
||||
- 属性测试:统一放在test/property/,进行随机化测试
|
||||
|
||||
3. **属性测试应用**
|
||||
- 管理员模块使用fast-check
|
||||
- 随机化用户状态变更测试
|
||||
- 边界条件自动发现
|
||||
|
||||
2. **集成测试重点**
|
||||
4. **集成测试重点**
|
||||
- WebSocket连接生命周期
|
||||
- 双模式服务一致性
|
||||
- 第三方服务集成
|
||||
|
||||
3. **E2E测试场景**
|
||||
5. **E2E测试场景**
|
||||
- 完整的用户游戏流程
|
||||
- 多用户实时交互
|
||||
- 异常恢复和降级
|
||||
|
||||
6. **测试执行顺序**
|
||||
- 第一阶段:单元测试(快速反馈)
|
||||
- 第二阶段:集成测试(模块协作)
|
||||
- 第三阶段:E2E测试(业务流程)
|
||||
- 第四阶段:性能测试(系统性能)
|
||||
|
||||
### 📊 监控和告警
|
||||
|
||||
1. **关键指标监控**
|
||||
|
||||
Reference in New Issue
Block a user