Compare commits
4 Commits
feature/ch
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b142e7de76 | |||
| 9483d6ab20 | |||
|
|
4165a4c03a | ||
|
|
2b87eac495 |
61
.env.example
61
.env.example
@@ -15,15 +15,6 @@ 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=测试用户
|
||||
|
||||
# ===========================================
|
||||
# 管理员后台配置(开发环境推荐配置)
|
||||
# ===========================================
|
||||
@@ -33,10 +24,10 @@ ADMIN_TOKEN_SECRET=dev_admin_token_secret_change_me_32chars
|
||||
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||
|
||||
# 启动引导创建管理员账号(仅当 enabled=true 时生效)
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=Admin123456
|
||||
ADMIN_NICKNAME=管理员
|
||||
ADMIN_BOOTSTRAP_ENABLED=false
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=Admin123456
|
||||
# ADMIN_NICKNAME=管理员
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=test_jwt_secret_key_for_development_only_32chars
|
||||
@@ -54,26 +45,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.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>"
|
||||
# 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>
|
||||
|
||||
# 生产环境设置(生产环境取消注释)
|
||||
# NODE_ENV=production
|
||||
@@ -83,19 +74,13 @@ EMAIL_FROM="whaletown <your_email@163.com>"
|
||||
# Zulip 集成配置
|
||||
# ===========================================
|
||||
|
||||
# Zulip 配置模式
|
||||
# static: 使用静态配置文件 (config/zulip/map-config.json)
|
||||
# dynamic: 从Zulip服务器动态获取Stream作为地图
|
||||
# hybrid: 混合模式,优先动态,回退静态 (推荐)
|
||||
ZULIP_CONFIG_MODE=hybrid
|
||||
|
||||
# Zulip 服务器配置
|
||||
ZULIP_SERVER_URL=https://your-zulip-server.com/
|
||||
ZULIP_BOT_EMAIL=your-bot@your-zulip-server.com
|
||||
ZULIP_SERVER_URL=https://zulip.xinghangee.icu/
|
||||
ZULIP_BOT_EMAIL=cbot-bot@zulip.xinghangee.icu
|
||||
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
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -45,8 +45,4 @@ coverage/
|
||||
# Redis数据文件(本地开发用)
|
||||
redis-data/
|
||||
|
||||
.kiro/
|
||||
|
||||
config/
|
||||
docs/merge-requests
|
||||
docs/ai-reading/me.config.json
|
||||
.kiro/
|
||||
227
AI代码检查规范_简洁版.md
Normal file
227
AI代码检查规范_简洁版.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# AI代码检查规范(简洁版)
|
||||
|
||||
## 执行原则
|
||||
- **分步执行**:每次只执行一个步骤,完成后等待用户确认
|
||||
- **用户信息收集**:开始前必须收集用户当前日期和名称
|
||||
- **修改验证**:每次修改后必须重新检查该步骤
|
||||
|
||||
## 检查步骤
|
||||
|
||||
### 步骤1:命名规范检查
|
||||
- **文件/文件夹**:snake_case(下划线分隔),严禁kebab-case
|
||||
- **变量/函数**:camelCase
|
||||
- **类/接口**:PascalCase
|
||||
- **常量**:SCREAMING_SNAKE_CASE
|
||||
- **路由**:kebab-case
|
||||
- **文件夹优化**:删除单文件文件夹,扁平化结构
|
||||
- **Core层命名**:业务支撑模块用_core后缀,通用工具模块不用
|
||||
|
||||
#### 文件夹结构检查要求
|
||||
**必须使用listDirectory工具详细检查每个文件夹的内容:**
|
||||
1. 使用`listDirectory(path, depth=2)`获取完整文件夹结构
|
||||
2. 统计每个文件夹内的文件数量
|
||||
3. 识别只有1个文件的文件夹(单文件文件夹)
|
||||
4. 将单文件文件夹中的文件移动到上级目录
|
||||
5. 更新所有相关的import路径引用
|
||||
|
||||
**检查标准:**
|
||||
- 不超过3个文件的文件夹:必须扁平化处理
|
||||
- 4个以上文件:通常保持独立文件夹
|
||||
- 完整功能模块:即使文件较少也可以保持独立(需特殊说明)
|
||||
- **测试文件位置**:测试文件必须与对应源文件放在同一目录,不允许单独的tests文件夹
|
||||
|
||||
**测试文件位置规范(重要):**
|
||||
- ✅ 正确:`src/business/admin/admin.service.ts` 和 `src/business/admin/admin.service.spec.ts` 同目录
|
||||
- ❌ 错误:`src/business/admin/tests/admin.service.spec.ts` 单独tests文件夹
|
||||
- **强制要求**:所有tests/、test/等测试专用文件夹必须扁平化,测试文件移动到源文件同目录
|
||||
- **扁平化处理**:包括tests/、test/、spec/、__tests__/等所有测试文件夹都必须扁平化
|
||||
|
||||
**常见错误:**
|
||||
- 只看文件夹名称,不检查内容
|
||||
- 凭印象判断,不使用工具获取准确数据
|
||||
- 遗漏3个文件以下文件夹的识别
|
||||
- **忽略测试文件夹**:认为tests文件夹是"标准结构"而不进行扁平化检查
|
||||
|
||||
### 步骤2:注释规范检查
|
||||
- **文件头注释**:功能描述、职责分离、修改记录、@author、@version、@since、@lastModified
|
||||
- **类注释**:职责、主要方法、使用场景
|
||||
- **方法注释**:业务逻辑步骤、@param、@returns、@throws、@example
|
||||
- **修改记录**:使用用户提供的日期和名称,格式"日期: 类型 - 内容 (修改者: 名称)"
|
||||
- **@author处理规范**:
|
||||
- **保留原则**:人名必须保留,不得随意修改
|
||||
- **AI标识替换**:只有AI标识(kiro、ChatGPT、Claude、AI等)才可替换为用户名称
|
||||
- **判断示例**:`@author kiro` → 可替换,`@author 张三` → 必须保留
|
||||
- **版本号递增**:规范优化/Bug修复→修订版本+1,功能变更→次版本+1,重构→主版本+1
|
||||
- **时间更新**:只有真正修改了文件内容时才更新@lastModified字段,仅检查不修改内容时不更新日期
|
||||
|
||||
### 步骤3:代码质量检查
|
||||
- **清理未使用**:导入、变量、方法
|
||||
- **常量定义**:使用SCREAMING_SNAKE_CASE
|
||||
- **方法长度**:建议不超过50行
|
||||
- **代码重复**:识别并消除重复代码
|
||||
- **魔法数字**:提取为常量定义
|
||||
- **工具函数**:抽象重复逻辑为可复用函数
|
||||
|
||||
### 步骤4:架构分层检查
|
||||
- **检查范围**:仅检查当前执行检查的文件夹,不考虑其他同层功能模块
|
||||
- **Core层**:专注技术实现,不含业务逻辑
|
||||
- **Core层命名规则**:
|
||||
- **业务支撑模块**:为特定业务功能提供技术支撑,使用`_core`后缀(如:`location_broadcast_core`)
|
||||
- **通用工具模块**:提供可复用的数据访问或技术服务,不使用后缀(如:`user_profiles`、`redis_cache`)
|
||||
- **判断方法**:检查模块是否专门为某个业务服务,如果是则使用`_core`后缀,如果是通用服务则不使用
|
||||
- **Business层**:专注业务逻辑,不含技术实现细节
|
||||
- **依赖关系**:Core层不能导入Business层,Business层通过依赖注入使用Core层
|
||||
- **职责分离**:确保各层职责清晰,边界明确
|
||||
|
||||
### 步骤5:测试覆盖检查
|
||||
- **测试文件存在性**:每个Service必须有.spec.ts文件
|
||||
- **Service定义**:只有以下类型需要测试文件
|
||||
- ✅ **Service类**:文件名包含`.service.ts`的业务逻辑类
|
||||
- ✅ **Controller类**:文件名包含`.controller.ts`的控制器类
|
||||
- ✅ **Gateway类**:文件名包含`.gateway.ts`的WebSocket网关类
|
||||
- ❌ **Middleware类**:中间件不需要测试文件
|
||||
- ❌ **Guard类**:守卫不需要测试文件
|
||||
- ❌ **DTO类**:数据传输对象不需要测试文件
|
||||
- ❌ **Interface文件**:接口定义不需要测试文件
|
||||
- ❌ **Utils工具类**:工具函数不需要测试文件
|
||||
- **方法覆盖**:所有公共方法必须有测试
|
||||
- **场景覆盖**:正常、异常、边界情况
|
||||
- **测试质量**:真实有效的测试用例,不是空壳
|
||||
- **集成测试**:复杂Service需要.integration.spec.ts
|
||||
- **测试执行**:必须执行测试命令验证通过
|
||||
|
||||
### 步骤6:功能文档生成
|
||||
- **README结构**:模块概述、对外接口、内部依赖、核心特性、潜在风险
|
||||
- **接口描述**:每个公共方法一句话功能说明
|
||||
- **依赖分析**:列出所有项目内部依赖及用途
|
||||
- **特性识别**:技术特性、功能特性、质量特性
|
||||
- **风险评估**:技术风险、业务风险、运维风险、安全风险
|
||||
|
||||
## 关键规则
|
||||
|
||||
### 命名规范
|
||||
```typescript
|
||||
// 文件命名
|
||||
✅ user_service.ts, create_user_dto.ts
|
||||
❌ user-service.ts, UserService.ts
|
||||
|
||||
// 变量命名
|
||||
✅ const userName = 'test';
|
||||
❌ const UserName = 'test';
|
||||
|
||||
// 常量命名
|
||||
✅ const MAX_RETRY_COUNT = 3;
|
||||
❌ const maxRetryCount = 3;
|
||||
```
|
||||
|
||||
### 注释规范
|
||||
```typescript
|
||||
/**
|
||||
* 文件功能描述
|
||||
*
|
||||
* 功能描述:
|
||||
* - 功能点1
|
||||
* - 功能点2
|
||||
*
|
||||
* 最近修改:
|
||||
* - [用户日期]: 修改类型 - 修改内容 (修改者: [用户名称])
|
||||
*
|
||||
* @author [处理后的作者名称]
|
||||
* @version x.x.x
|
||||
* @since [创建日期]
|
||||
* @lastModified [用户日期]
|
||||
*/
|
||||
```
|
||||
|
||||
**@author字段处理规则:**
|
||||
- **保留人名**:如果@author是人名,必须保留不变
|
||||
- **替换AI标识**:只有AI标识(kiro、ChatGPT、Claude、AI等)才可替换
|
||||
- **示例**:
|
||||
- `@author kiro` → 可替换为 `@author [用户名称]`
|
||||
- `@author 张三` → 必须保留为 `@author 张三`
|
||||
|
||||
### 架构分层
|
||||
```typescript
|
||||
// Core层 - 业务支撑模块(使用_core后缀)
|
||||
@Injectable()
|
||||
export class LocationBroadcastCoreService {
|
||||
async broadcastPosition(data: PositionData): Promise<void> {
|
||||
// 为位置广播业务提供技术支撑
|
||||
}
|
||||
}
|
||||
|
||||
// Core层 - 通用工具模块(不使用后缀)
|
||||
@Injectable()
|
||||
export class UserProfilesService {
|
||||
async findByUserId(userId: bigint): Promise<UserProfile> {
|
||||
// 通用的用户档案数据访问服务
|
||||
}
|
||||
}
|
||||
|
||||
// Business层 - 业务逻辑
|
||||
@Injectable()
|
||||
export class LocationBroadcastService {
|
||||
constructor(
|
||||
private readonly locationBroadcastCore: LocationBroadcastCoreService,
|
||||
private readonly userProfiles: UserProfilesService
|
||||
) {}
|
||||
|
||||
async updateUserLocation(userId: string, position: Position): Promise<void> {
|
||||
// 业务逻辑:验证、调用Core层、返回结果
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Core层命名判断标准:**
|
||||
- **业务支撑模块**:专门为某个业务功能提供技术支撑 → 使用`_core`后缀
|
||||
- **通用工具模块**:提供可复用的数据访问或基础服务 → 不使用后缀
|
||||
|
||||
### 测试覆盖
|
||||
```typescript
|
||||
describe('UserService', () => {
|
||||
describe('createUser', () => {
|
||||
it('should create user successfully', () => {}); // 正常情况
|
||||
it('should throw error when email exists', () => {}); // 异常情况
|
||||
it('should handle empty name', () => {}); // 边界情况
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 执行模板
|
||||
|
||||
每步完成后使用此模板报告:
|
||||
|
||||
```
|
||||
## 步骤X:[步骤名称]检查报告
|
||||
|
||||
### 🔍 检查结果
|
||||
[发现的问题列表]
|
||||
|
||||
### 🛠️ 修正方案
|
||||
[具体修正建议]
|
||||
|
||||
### ✅ 完成状态
|
||||
- 检查项1 ✓/✗
|
||||
- 检查项2 ✓/✗
|
||||
|
||||
**请确认修正方案,确认后进行下一步骤**
|
||||
```
|
||||
|
||||
## 修改验证流程
|
||||
|
||||
修改后必须:
|
||||
1. 重新执行该步骤检查
|
||||
2. 提供验证报告
|
||||
3. 确认问题是否解决
|
||||
4. 等待用户确认
|
||||
|
||||
## 强制要求
|
||||
|
||||
- **用户信息**:开始前必须收集用户日期和名称
|
||||
- **分步执行**:严禁一次执行多步骤
|
||||
- **等待确认**:每步完成后必须等待用户确认
|
||||
- **修改验证**:修改后必须重新检查验证
|
||||
- **测试执行**:步骤5必须执行实际测试命令
|
||||
- **日期使用**:所有日期字段使用用户提供的真实日期
|
||||
- **作者字段保护**:@author字段中的人名不得修改,只有AI标识才可替换
|
||||
- **修改记录强制**:每次修改文件必须添加修改记录和更新@lastModified
|
||||
492
README.md
492
README.md
@@ -1,30 +1,35 @@
|
||||
# 🐋 Whale Town - 像素游戏后端服务
|
||||
|
||||
> 基于 NestJS 的现代化 2D 像素游戏后端,采用四层架构(Gateway-Business-Core-Data),支持用户认证、实时通信、Zulip集成、管理员后台。
|
||||
> 一个基于 NestJS 的现代化 2D 像素风游戏后端服务,采用业务功能模块化架构,支持用户认证、管理员后台、安全防护等完整功能。
|
||||
|
||||
[](https://nodejs.org/)
|
||||
[](https://nestjs.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](#)
|
||||
[](./LICENSE)
|
||||
|
||||
## 🎯 核心特性
|
||||
## 🎯 项目简介
|
||||
|
||||
- 🔐 用户认证:多方式登录、验证码登录、GitHub OAuth
|
||||
- 🌐 实时通信:原生WebSocket、位置广播、地图房间管理
|
||||
- 💬 Zulip集成:游戏内聊天与Zulip社群双向同步
|
||||
- 👑 管理员后台:React界面、用户管理、日志监控
|
||||
- 🛡️ 安全防护:频率限制、维护模式、JWT认证
|
||||
- 🗄️ 灵活存储:MySQL/内存双模式、Redis/文件双模式
|
||||
- 📚 完整文档:Swagger UI、WebSocket测试工具
|
||||
Whale Town 是一个功能完整的像素游戏后端服务,采用业务功能模块化架构设计:
|
||||
|
||||
- 🔐 **用户认证模块** - 完整的登录、注册、密码管理、邮箱验证系统
|
||||
- 👥 **用户管理模块** - 用户状态管理、批量操作、状态统计功能
|
||||
- 🛡️ **管理员模块** - 管理员认证、用户管理、密码重置、日志查看
|
||||
- 🔒 **安全模块** - 频率限制、维护模式、超时控制、内容类型检查
|
||||
- 📧 **智能邮件服务** - 支持测试模式和生产模式自动切换
|
||||
- 🗄️ **灵活存储方案** - Redis文件存储 + 内存数据库,支持无依赖测试
|
||||
- 📚 **完整API文档** - Swagger UI + OpenAPI规范,17个接口完整覆盖
|
||||
- 🧪 **全面测试覆盖** - 140个单元测试用例全部通过
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js >= 18.0.0
|
||||
- pnpm >= 8.0.0
|
||||
### 📋 环境要求
|
||||
|
||||
### 安装运行
|
||||
- **Node.js** >= 18.0.0 (推荐 24.7.0)
|
||||
- **pnpm** >= 8.0.0 (推荐 10.25.0)
|
||||
|
||||
### 🛠️ 安装与运行
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
@@ -34,191 +39,432 @@ cd whale-town-end
|
||||
# 2. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 3. 配置环境(测试模式,无需数据库)
|
||||
# 3. 配置环境(测试模式,无需数据库和邮件服务器)
|
||||
cp .env.example .env
|
||||
|
||||
# 4. 启动服务
|
||||
# 4. 启动开发服务器
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问:http://localhost:3000
|
||||
🎉 **服务启动成功!** 访问 http://localhost:3000
|
||||
|
||||
### 前端管理界面
|
||||
### 🧑💻 前端管理界面
|
||||
|
||||
项目包含一个功能完整的前端管理界面,位于 `client/` 目录:
|
||||
|
||||
**🎛️ 核心功能:**
|
||||
- 管理员身份认证(独立Token系统)
|
||||
- 用户列表管理与搜索
|
||||
- 用户密码重置功能
|
||||
- 运行时日志查看与下载
|
||||
- 响应式界面设计
|
||||
|
||||
**🚀 快速启动:**
|
||||
|
||||
```bash
|
||||
# 启动管理后台
|
||||
# 1. 启动后端服务
|
||||
pnpm run dev
|
||||
|
||||
# 2. 启动前端管理界面
|
||||
cd client
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
|
||||
# 3. 访问管理后台
|
||||
# 地址: http://localhost:5173
|
||||
# 默认账号: admin / Admin123456
|
||||
```
|
||||
|
||||
访问:http://localhost:5173
|
||||
默认账号:admin / Admin123456
|
||||
### 🧪 快速测试
|
||||
|
||||
### 在线体验
|
||||
```bash
|
||||
# 运行综合测试(推荐)
|
||||
.\test-comprehensive.ps1
|
||||
|
||||
- API文档:https://whaletownend.xinghangee.icu/api-docs
|
||||
- WebSocket测试:https://whaletownend.xinghangee.icu/websocket-test
|
||||
# 跳过限流测试(更快)
|
||||
.\test-comprehensive.ps1 -SkipThrottleTest
|
||||
|
||||
## 🏗️ 项目架构
|
||||
|
||||
### 四层架构设计
|
||||
|
||||
```
|
||||
Gateway Layer (网关层)
|
||||
↓ HTTP/WebSocket协议处理、数据验证
|
||||
Business Layer (业务层)
|
||||
↓ 业务逻辑实现、服务协调
|
||||
Core Layer (核心层)
|
||||
↓ 技术基础设施、数据访问
|
||||
Data Layer (数据层)
|
||||
↓ 数据持久化、缓存管理
|
||||
# 测试远程服务器
|
||||
.\test-comprehensive.ps1 -BaseUrl "https://your-server.com"
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
**测试内容:**
|
||||
- ✅ 应用状态检查
|
||||
- ✅ 邮箱验证码发送与验证
|
||||
- ✅ 用户注册与登录
|
||||
- ✅ 验证码登录功能
|
||||
- ✅ 密码重置流程
|
||||
- ✅ 邮箱冲突检测
|
||||
- ✅ 验证码冷却时间清除
|
||||
- ✅ 限流保护机制
|
||||
- ✅ Redis文件存储功能
|
||||
- ✅ 邮件测试模式
|
||||
|
||||
---
|
||||
|
||||
## 🎓 新开发者指南
|
||||
|
||||
### 第一步:了解项目规范 📚
|
||||
|
||||
**⚠️ 重要:在开始开发前,请务必阅读以下文档**
|
||||
|
||||
1. **[AI辅助开发规范指南](./docs/AI辅助开发规范指南.md)** 🤖
|
||||
- 学会使用AI助手提升开发效率300%
|
||||
- 自动生成符合规范的代码和注释
|
||||
- 实时检查代码质量
|
||||
|
||||
2. **[后端开发规范](./docs/backend_development_guide.md)** 📝
|
||||
- 代码注释标准
|
||||
- 业务逻辑设计原则
|
||||
- 日志记录要求
|
||||
|
||||
3. **[Git提交规范](./docs/git_commit_guide.md)** 🔄
|
||||
- 提交信息格式
|
||||
- 分支管理策略
|
||||
|
||||
### 第二步:熟悉项目架构 🏗️
|
||||
|
||||
**📁 项目文件结构总览**
|
||||
|
||||
```
|
||||
whale-town-end/
|
||||
├── src/
|
||||
│ ├── gateway/ # 网关层:auth, location_broadcast
|
||||
│ ├── business/ # 业务层:auth, user_mgmt, admin, zulip, notice
|
||||
│ ├── core/ # 核心层:db, redis, login_core, admin_core, utils
|
||||
│ ├── app.module.ts
|
||||
│ └── main.ts
|
||||
├── client/ # React管理界面
|
||||
├── docs/ # 项目文档
|
||||
├── test/ # 测试文件
|
||||
└── config/ # 配置文件
|
||||
whale-town-end/ # 🐋 项目根目录
|
||||
├── 📂 src/ # 源代码目录
|
||||
│ ├── 📂 business/ # 🎯 业务功能模块(按功能组织)
|
||||
│ │ ├── 📂 auth/ # 🔐 用户认证模块
|
||||
│ │ ├── 📂 user-mgmt/ # 👥 用户管理模块
|
||||
│ │ ├── 📂 admin/ # 🛡️ 管理员模块
|
||||
│ │ ├── 📂 security/ # 🔒 安全防护模块
|
||||
│ │ ├── 📂 zulip/ # 💬 Zulip集成模块
|
||||
│ │ └── 📂 shared/ # 🔗 共享业务组件
|
||||
│ ├── 📂 core/ # ⚙️ 核心技术服务
|
||||
│ │ ├── 📂 db/ # 🗄️ 数据库层(MySQL/内存双模式)
|
||||
│ │ ├── 📂 redis/ # 🔴 Redis缓存(真实Redis/文件存储)
|
||||
│ │ ├── 📂 login_core/ # 🔑 登录核心服务
|
||||
│ │ ├── 📂 admin_core/ # 👑 管理员核心服务
|
||||
│ │ ├── 📂 zulip/ # 💬 Zulip核心服务
|
||||
│ │ └── 📂 utils/ # 🛠️ 工具服务(邮件、验证码、日志)
|
||||
│ ├── 📄 app.module.ts # 🏠 应用主模块
|
||||
│ └── 📄 main.ts # 🚀 应用入口点
|
||||
├── 📂 client/ # 🎨 前端管理界面
|
||||
│ ├── 📂 src/ # 前端源码
|
||||
│ ├── 📂 dist/ # 前端构建产物
|
||||
│ ├── 📄 package.json # 前端依赖配置
|
||||
│ └── 📄 vite.config.ts # Vite构建配置
|
||||
├── 📂 docs/ # 📚 项目文档中心
|
||||
│ ├── 📂 api/ # 🔌 API接口文档
|
||||
│ ├── 📂 development/ # 💻 开发指南
|
||||
│ ├── 📂 deployment/ # 🚀 部署文档
|
||||
│ ├── 📄 ARCHITECTURE.md # 🏗️ 架构设计文档
|
||||
│ └── 📄 README.md # 📖 文档导航中心
|
||||
├── 📂 test/ # 🧪 测试文件目录
|
||||
├── 📂 config/ # ⚙️ 配置文件目录
|
||||
├── 📂 logs/ # 📝 日志文件存储
|
||||
├── 📂 redis-data/ # 💾 Redis文件存储数据
|
||||
├── 📂 dist/ # 📦 后端构建产物
|
||||
├── 📄 .env # 🔧 环境变量配置
|
||||
├── 📄 package.json # 📋 项目依赖配置
|
||||
├── 📄 docker-compose.yml # 🐳 Docker编排配置
|
||||
├── 📄 Dockerfile # 🐳 Docker镜像配置
|
||||
└── 📄 README.md # 📖 项目主文档(当前文件)
|
||||
```
|
||||
|
||||
详细架构:[docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)
|
||||
**架构特点:**
|
||||
- 🏗️ **业务功能模块化** - 按业务功能而非技术组件组织代码
|
||||
- 🔄 **双模式支持** - 开发测试模式 + 生产部署模式
|
||||
- 📦 **清晰分层** - 业务层 → 核心层 → 数据层
|
||||
- 🧪 **测试友好** - 完整的单元测试和集成测试覆盖
|
||||
|
||||
### 第三步:体验核心功能 🎮
|
||||
|
||||
1. **API文档系统** 📖
|
||||
```bash
|
||||
# 启动服务后访问
|
||||
http://localhost:3000/api-docs
|
||||
```
|
||||
|
||||
2. **用户认证系统** 🔐
|
||||
- 邮箱验证码注册
|
||||
- 多方式登录(用户名/邮箱/手机号)
|
||||
- 密码重置功能
|
||||
|
||||
3. **实时通信** 🌐
|
||||
- WebSocket支持
|
||||
- Socket.IO集成
|
||||
|
||||
### 第四步:开始贡献 🤝
|
||||
|
||||
1. **Fork项目** 到你的Gitea账户
|
||||
2. **创建功能分支**:`git checkout -b feature/your-feature`
|
||||
3. **遵循规范开发**(使用AI助手帮助)
|
||||
4. **提交代码**:`git commit -m "feat:添加新功能"`
|
||||
5. **创建Pull Request**
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
**后端:** NestJS 11 + TypeScript 5 + MySQL + Redis + WebSocket
|
||||
**前端:** React 18 + Vite 7 + Ant Design 5
|
||||
**测试:** Jest + Supertest(99个测试用例)
|
||||
**部署:** Docker + PM2 + Nginx
|
||||
### 🚀 核心框架
|
||||
- **NestJS** `^11.1.9` - 企业级Node.js框架,提供依赖注入、模块化等特性
|
||||
- **TypeScript** `^5.9.3` - 类型安全的JavaScript超集
|
||||
- **Express** `^10.4.20` - 基于Express的HTTP服务器
|
||||
- **RxJS** `^7.8.2` - 响应式编程库,处理异步数据流
|
||||
|
||||
## 📊 开发命令
|
||||
### 🌐 实时通信
|
||||
- **Socket.IO** `^10.4.20` - WebSocket实时双向通信
|
||||
- **@nestjs/websockets** - NestJS WebSocket网关支持
|
||||
- **@nestjs/platform-socket.io** - Socket.IO平台适配器
|
||||
|
||||
### 🗄️ 数据存储
|
||||
- **TypeORM** `^0.3.28` - 强大的ORM框架,支持多种数据库
|
||||
- **MySQL2** `^3.16.0` - 高性能MySQL驱动
|
||||
- **IORedis** `^5.8.2` - Redis客户端,支持集群和哨兵模式
|
||||
- **文件存储** - 自研Redis文件存储适配器,支持无Redis开发
|
||||
|
||||
### 🔐 安全认证
|
||||
- **bcrypt** `^6.0.0` - 密码加密哈希算法
|
||||
- **class-validator** `^0.14.3` - 数据验证装饰器
|
||||
- **class-transformer** `^0.5.1` - 对象转换和序列化
|
||||
|
||||
### 📧 通信服务
|
||||
- **Nodemailer** `^6.10.1` - 邮件发送服务
|
||||
- **Axios** `^1.13.2` - HTTP客户端,支持第三方API调用
|
||||
|
||||
### 📚 API文档
|
||||
- **Swagger UI** `^5.0.1` - 交互式API文档界面
|
||||
- **@nestjs/swagger** `^11.2.3` - NestJS Swagger集成
|
||||
|
||||
### 🧑💻 管理员后台(前端)
|
||||
- **Vite** - 前端构建工具(本项目 admin dashboard 使用)
|
||||
- **React** - 前端 UI 框架
|
||||
- **React Router** - 前端路由
|
||||
- **Ant Design** - 企业级 UI 组件库
|
||||
|
||||
### 📊 日志监控
|
||||
- **Pino** `^10.1.0` - 高性能结构化日志库
|
||||
- **nestjs-pino** `^4.5.0` - NestJS Pino集成
|
||||
- **pino-pretty** `^13.1.3` - Pino日志美化输出
|
||||
|
||||
### 🧪 测试框架
|
||||
- **Jest** `^29.7.0` - JavaScript测试框架
|
||||
- **Supertest** `^7.1.4` - HTTP断言测试
|
||||
- **@nestjs/testing** `^10.4.20` - NestJS测试工具
|
||||
|
||||
### ⚙️ 开发工具
|
||||
- **@nestjs/cli** `^10.4.9` - NestJS命令行工具
|
||||
- **ts-jest** `^29.2.5` - TypeScript Jest支持
|
||||
- **ts-node** `^10.9.2` - TypeScript运行时
|
||||
- **pnpm** - 快速、节省磁盘空间的包管理器
|
||||
|
||||
### 🔄 任务调度
|
||||
- **@nestjs/schedule** `^4.1.2` - 定时任务和计划任务支持
|
||||
|
||||
### 📦 构建部署
|
||||
- **Docker** - 容器化部署
|
||||
- **PM2** - 生产环境进程管理
|
||||
- **Nginx** - 反向代理和负载均衡
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 核心功能
|
||||
|
||||
### 🔐 用户认证模块 (business/auth/)
|
||||
- **多方式登录** - 用户名/邮箱/手机号
|
||||
- **邮箱验证** - 完整的验证码流程
|
||||
- **密码安全** - bcrypt加密 + 强度验证
|
||||
- **第三方登录** - GitHub OAuth支持
|
||||
- **密码管理** - 忘记密码、重置密码、修改密码
|
||||
|
||||
### 👥 用户管理模块 (business/user-mgmt/)
|
||||
- **用户状态管理** - 6种状态控制(active、inactive、locked、banned、deleted、pending)
|
||||
- **批量操作** - 批量修改用户状态
|
||||
- **状态统计** - 各状态用户数量统计
|
||||
- **状态变更日志** - 完整的审计日志
|
||||
|
||||
### 🛡️ 管理员模块 (business/admin/)
|
||||
- **独立认证** - 专用Token系统,与用户系统隔离
|
||||
- **用户管理** - 用户列表、搜索、密码重置
|
||||
- **日志监控** - 实时日志查看、历史日志下载
|
||||
- **权限控制** - 管理员角色验证(role=9)
|
||||
|
||||
### 🔒 安全模块 (business/security/)
|
||||
- **频率限制** - 基于IP的请求频率控制
|
||||
- **维护模式** - 系统维护期间的访问控制
|
||||
- **内容类型验证** - HTTP请求内容类型检查
|
||||
- **超时控制** - 可配置的请求超时机制
|
||||
|
||||
### 📧 智能邮件服务
|
||||
- **测试模式** - 控制台输出,无需SMTP服务器
|
||||
- **生产模式** - 支持主流邮件服务商
|
||||
- **模板系统** - 验证码、欢迎邮件等模板
|
||||
- **自动切换** - 根据配置自动选择模式
|
||||
|
||||
### 🗄️ 灵活存储方案
|
||||
- **Redis文件存储** - 开发测试无需Redis服务器
|
||||
- **内存数据库** - 无需MySQL即可运行
|
||||
- **生产就绪** - 支持MySQL + Redis部署
|
||||
- **自动切换** - 根据配置自动选择存储方式
|
||||
|
||||
### 📚 完整API文档
|
||||
- **Swagger UI** - 交互式API文档
|
||||
- **OpenAPI规范** - 标准化接口描述
|
||||
- **Postman集合** - 可导入的测试集合
|
||||
- **实时更新** - 代码变更自动同步文档
|
||||
|
||||
### 🧪 全面测试覆盖
|
||||
- **单元测试** - 140个测试用例全部通过
|
||||
- **API测试** - 跨平台测试脚本
|
||||
- **集成测试** - 完整业务流程验证
|
||||
- **测试模式** - 无依赖快速测试
|
||||
|
||||
---
|
||||
|
||||
## 📊 开发与测试
|
||||
|
||||
### 🔧 开发命令
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm run dev # 启动开发服务器
|
||||
pnpm run build # 构建项目
|
||||
pnpm run start:prod # 生产环境运行
|
||||
# 开发服务器(热重载)
|
||||
pnpm run dev
|
||||
|
||||
# 测试
|
||||
pnpm test # 运行单元测试
|
||||
pnpm run test:cov # 测试覆盖率
|
||||
.\test-comprehensive.ps1 # API功能测试
|
||||
# 构建项目
|
||||
pnpm run build
|
||||
|
||||
# 生产环境运行
|
||||
pnpm run start:prod
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 格式化代码
|
||||
pnpm run format
|
||||
```
|
||||
|
||||
## 🌍 环境配置
|
||||
### 🧪 测试命令
|
||||
|
||||
### 开发环境(默认)
|
||||
```bash
|
||||
USE_FILE_REDIS=true # 使用文件存储(无需Redis)
|
||||
# 运行所有单元测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式运行测试
|
||||
pnpm run test:watch
|
||||
|
||||
# 生成测试覆盖率报告
|
||||
pnpm run test:cov
|
||||
|
||||
# API功能测试(综合测试脚本)
|
||||
.\test-comprehensive.ps1
|
||||
```
|
||||
|
||||
### 📈 测试覆盖率
|
||||
|
||||
- **单元测试**: 140个测试用例 ✅
|
||||
- **功能测试**: 用户认证、用户管理、管理员后台、安全防护 ✅
|
||||
- **集成测试**: 完整业务流程 ✅
|
||||
|
||||
---
|
||||
|
||||
## 🌍 部署配置
|
||||
|
||||
### 测试环境(默认)
|
||||
```bash
|
||||
# 无需数据库和邮件服务器
|
||||
USE_FILE_REDIS=true
|
||||
NODE_ENV=development
|
||||
# 无需配置数据库和邮件
|
||||
# 数据库和邮件配置保持注释状态
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
```bash
|
||||
# 启用真实服务
|
||||
USE_FILE_REDIS=false
|
||||
NODE_ENV=production
|
||||
|
||||
# 数据库
|
||||
# 配置数据库
|
||||
DB_HOST=your_mysql_host
|
||||
DB_USERNAME=your_username
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# Redis
|
||||
# 配置Redis
|
||||
REDIS_HOST=your_redis_host
|
||||
REDIS_PASSWORD=your_password
|
||||
|
||||
# 邮件
|
||||
EMAIL_HOST=smtp.163.com
|
||||
EMAIL_USER=your_email@163.com
|
||||
EMAIL_PASS=your_password
|
||||
|
||||
# Zulip
|
||||
ZULIP_SERVER_URL=https://your-zulip.com/
|
||||
ZULIP_BOT_API_KEY=your_api_key
|
||||
# 配置邮件服务
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_USER=your_email@gmail.com
|
||||
EMAIL_PASS=your_app_password
|
||||
```
|
||||
|
||||
详细配置:[docs/deployment/DEPLOYMENT.md](./docs/deployment/DEPLOYMENT.md)
|
||||
详细部署指南:[DEPLOYMENT.md](./DEPLOYMENT.md)
|
||||
|
||||
## 📚 文档
|
||||
---
|
||||
|
||||
- [架构设计](./docs/ARCHITECTURE.md) - 四层架构详解
|
||||
- [开发规范](./docs/development/backend_development_guide.md) - 代码规范
|
||||
- [Git规范](./docs/development/git_commit_guide.md) - 提交规范
|
||||
- [API文档](http://localhost:3000/api-docs) - Swagger UI
|
||||
- [测试指南](./docs/development/TESTING.md) - 测试说明
|
||||
## 📚 文档中心
|
||||
|
||||
### 🤖 AI代码检查指南
|
||||
### 🎯 新手必读
|
||||
1. **[AI辅助开发指南](./docs/AI辅助开发规范指南.md)** - 提升开发效率300%
|
||||
2. **[后端开发规范](./docs/backend_development_guide.md)** - 代码质量标准
|
||||
3. **[Git提交规范](./docs/git_commit_guide.md)** - 版本控制最佳实践
|
||||
|
||||
项目提供了完整的AI辅助代码检查流程,帮助确保代码质量和规范性。
|
||||
### 📖 API文档
|
||||
- **[Swagger UI](http://localhost:3000/api-docs)** - 交互式API文档
|
||||
- **[API文档总览](./docs/api/README.md)** - 使用指南
|
||||
- **[OpenAPI规范](./docs/api/openapi.yaml)** - 标准化描述
|
||||
- **[Postman集合](./docs/api/postman-collection.json)** - 测试集合
|
||||
|
||||
**快速开始:**
|
||||
### 🏗️ 系统设计
|
||||
- **[架构文档](./docs/ARCHITECTURE.md)** - 系统架构设计
|
||||
- **[部署指南](./docs/deployment/DEPLOYMENT.md)** - 生产环境部署
|
||||
|
||||
向AI发送以下prompt开始代码检查:
|
||||
### 🧪 测试指南
|
||||
- **[测试指南](./docs/development/TESTING.md)** - 完整测试说明
|
||||
- **[单元测试](./src/**/*.spec.ts)** - 测试用例参考
|
||||
|
||||
```
|
||||
请使用 docs/ai-reading 中readme的规范对 [模块路径] 进行完整的代码检查。
|
||||
```
|
||||
---
|
||||
|
||||
**如何使用:**
|
||||
- AI会按照7个步骤逐步执行检查(命名规范、注释标准、代码质量、架构层级、测试覆盖、文档生成、代码提交)
|
||||
- 每个步骤完成后会提供检查报告,等待确认后继续下一步
|
||||
- 如有问题会自动修复并重新验证
|
||||
- 这里建议每个步骤结束后,人工确认是否执行了修复,如果进行了修复,请告诉他:请重新执行一遍该步骤,看看是否有遗漏。
|
||||
## 🤝 贡献者
|
||||
|
||||
详细说明:[docs/ai-reading/README.md](./docs/ai-reading/README.md) | 开发者规范:[docs/开发者代码检查规范.md](./docs/开发者代码检查规范.md)
|
||||
感谢所有为项目做出贡献的开发者!
|
||||
|
||||
## 🤝 参与贡献
|
||||
### 🏆 核心团队
|
||||
- **[moyin](https://gitea.xinghangee.icu/moyin)** - 核心开发者
|
||||
- **[jianuo](https://gitea.xinghangee.icu/jianuo)** - 核心开发者
|
||||
- **[angjustinl](https://gitea.xinghangee.icu/ANGJustinl)** - 核心开发者
|
||||
|
||||
### 贡献流程
|
||||
1. Fork项目
|
||||
2. 创建分支:`git checkout -b feature/your-feature`
|
||||
3. 开发功能(遵循开发规范)
|
||||
4. 运行测试:`pnpm test`
|
||||
5. 提交代码:`git commit -m "feat: 添加新功能"`
|
||||
6. 创建Pull Request
|
||||
查看完整贡献者名单:[docs/CONTRIBUTORS.md](./docs/CONTRIBUTORS.md)
|
||||
|
||||
### 核心团队
|
||||
- [moyin](https://gitea.xinghangee.icu/moyin)
|
||||
- [jianuo](https://gitea.xinghangee.icu/jianuo)
|
||||
- [angjustinl](https://gitea.xinghangee.icu/ANGJustinl)
|
||||
### 🌟 如何贡献
|
||||
|
||||
完整贡献者:[docs/CONTRIBUTORS.md](./docs/CONTRIBUTORS.md)
|
||||
我们欢迎所有形式的贡献:
|
||||
|
||||
## 📝 版本历史
|
||||
1. **<EFBFBD> Bug修复** - 发现并修复问题
|
||||
2. **✨ 新功能** - 添加有价值的功能
|
||||
3. **<EFBFBD> 文档改馈进** - 完善项目文档
|
||||
4. **🧪 测试用例** - 提高代码覆盖率
|
||||
5. **💡 建议反馈** - 提出改进建议
|
||||
|
||||
- **v2.1.0** (2026-01) - WebSocket架构升级、地图房间管理
|
||||
- **v2.0.0** (2025-12) - 四层架构重构、Zulip集成、管理员后台
|
||||
- **v1.2.0** (2025-11) - 用户管理、安全防护、邮件服务
|
||||
- **v1.0.0** (2025-10) - 项目初始化、用户认证、双模式存储
|
||||
**贡献流程:**
|
||||
1. Fork项目 → 2. 创建分支 → 3. 开发功能 → 4. 提交PR
|
||||
|
||||
## 📞 联系方式
|
||||
---
|
||||
|
||||
- 项目地址:[Gitea Repository](https://gitea.xinghangee.icu/datawhale/whale-town-end)
|
||||
- 问题反馈:[Issues](https://gitea.xinghangee.icu/datawhale/whale-town-end/issues)
|
||||
- 功能建议:[Discussions](https://gitea.xinghangee.icu/datawhale/whale-town-end/discussions)
|
||||
## 📞 联系我们
|
||||
|
||||
- **项目地址**: [Gitea Repository](https://gitea.xinghangee.icu/datawhale/whale-town-end)
|
||||
- **问题反馈**: [Issues](https://gitea.xinghangee.icu/datawhale/whale-town-end/issues)
|
||||
- **功能建议**: [Discussions](https://gitea.xinghangee.icu/datawhale/whale-town-end/discussions)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](./LICENSE)
|
||||
本项目采用 [MIT License](./LICENSE) 开源协议。
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**🐋 Whale Town - 让像素世界更精彩 !**
|
||||
**🐋 Whale Town - 让像素世界更精彩!**
|
||||
|
||||
Made with ❤️ by the Whale Town Team
|
||||
|
||||
[⭐ Star](https://gitea.xinghangee.icu/datawhale/whale-town-end) | [🍴 Fork](https://gitea.xinghangee.icu/datawhale/whale-town-end/fork) | [📖 Docs](./docs/) | [🐛 Issues](https://gitea.xinghangee.icu/datawhale/whale-town-end/issues)
|
||||
|
||||
</div>
|
||||
</div>
|
||||
149
config/zulip/README.md
Normal file
149
config/zulip/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Zulip配置目录
|
||||
|
||||
本目录包含Zulip集成系统的配置文件。
|
||||
|
||||
## 文件说明
|
||||
|
||||
### map-config.json
|
||||
|
||||
地图映射配置文件,定义游戏地图到Zulip Stream/Topic的映射关系。
|
||||
|
||||
#### 配置结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2025-12-25T00:00:00.000Z",
|
||||
"description": "配置描述",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "地图唯一标识",
|
||||
"mapName": "地图显示名称",
|
||||
"zulipStream": "对应的Zulip Stream名称",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "交互对象唯一标识",
|
||||
"objectName": "交互对象显示名称",
|
||||
"zulipTopic": "对应的Zulip Topic名称",
|
||||
"position": { "x": 100, "y": 150 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| version | string | 否 | 配置版本号 |
|
||||
| lastModified | string | 否 | 最后修改时间(ISO 8601格式) |
|
||||
| description | string | 否 | 配置描述 |
|
||||
| maps | array | 是 | 地图配置数组 |
|
||||
|
||||
##### 地图配置 (MapConfig)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| mapId | string | 是 | 地图唯一标识,如 "novice_village" |
|
||||
| mapName | string | 是 | 地图显示名称,如 "新手村" |
|
||||
| zulipStream | string | 是 | 对应的Zulip Stream名称 |
|
||||
| interactionObjects | array | 是 | 交互对象配置数组 |
|
||||
|
||||
##### 交互对象配置 (InteractionObject)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| objectId | string | 是 | 交互对象唯一标识 |
|
||||
| objectName | string | 是 | 交互对象显示名称 |
|
||||
| zulipTopic | string | 是 | 对应的Zulip Topic名称 |
|
||||
| position | object | 是 | 对象在地图中的位置 |
|
||||
| position.x | number | 是 | X坐标 |
|
||||
| position.y | number | 是 | Y坐标 |
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 新手村配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mapId": "novice_village",
|
||||
"mapName": "新手村",
|
||||
"zulipStream": "Novice Village",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "notice_board",
|
||||
"objectName": "公告板",
|
||||
"zulipTopic": "Notice Board",
|
||||
"position": { "x": 100, "y": 150 }
|
||||
},
|
||||
{
|
||||
"objectId": "village_well",
|
||||
"objectName": "村井",
|
||||
"zulipTopic": "Village Well",
|
||||
"position": { "x": 200, "y": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 酒馆配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mapId": "tavern",
|
||||
"mapName": "酒馆",
|
||||
"zulipStream": "Tavern",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "bar_counter",
|
||||
"objectName": "吧台",
|
||||
"zulipTopic": "Bar Counter",
|
||||
"position": { "x": 150, "y": 100 }
|
||||
},
|
||||
{
|
||||
"objectId": "fireplace",
|
||||
"objectName": "壁炉",
|
||||
"zulipTopic": "Fireplace Chat",
|
||||
"position": { "x": 300, "y": 200 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 热重载
|
||||
|
||||
配置文件支持热重载,修改后无需重启服务即可生效。
|
||||
|
||||
### 启用配置监听
|
||||
|
||||
在代码中调用:
|
||||
|
||||
```typescript
|
||||
configManagerService.enableConfigWatcher();
|
||||
```
|
||||
|
||||
### 手动重载配置
|
||||
|
||||
```typescript
|
||||
await configManagerService.reloadConfig();
|
||||
```
|
||||
|
||||
## 验证配置
|
||||
|
||||
系统启动时会自动验证配置文件的有效性。验证规则包括:
|
||||
|
||||
1. mapId必须是非空字符串
|
||||
2. mapName必须是非空字符串
|
||||
3. zulipStream必须是非空字符串
|
||||
4. interactionObjects必须是数组
|
||||
5. 每个交互对象必须有有效的objectId、objectName、zulipTopic和position
|
||||
6. position.x和position.y必须是有效数字
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Stream名称**: Zulip Stream名称区分大小写,请确保与Zulip服务器上的Stream名称完全匹配
|
||||
2. **Topic名称**: Topic名称同样区分大小写
|
||||
3. **位置坐标**: 位置坐标用于空间过滤,确保与游戏客户端的坐标系统一致
|
||||
4. **唯一性**: mapId和objectId在各自范围内必须唯一
|
||||
205
config/zulip/map-config.json
Normal file
205
config/zulip/map-config.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2025-12-25T20:00:00.000Z",
|
||||
"description": "基于设计图的 Zulip 映射配置",
|
||||
"maps": [
|
||||
{
|
||||
"mapId": "whale_port",
|
||||
"mapName": "鲸之港",
|
||||
"zulipStream": "Whale Port",
|
||||
"description": "中心城区,交通枢纽与主要聚会点",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "whale_statue",
|
||||
"objectName": "鲸鱼雕像",
|
||||
"zulipTopic": "Announcements",
|
||||
"position": { "x": 600, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "clock_tower",
|
||||
"objectName": "大本钟",
|
||||
"zulipTopic": "General Chat",
|
||||
"position": { "x": 550, "y": 350 }
|
||||
},
|
||||
{
|
||||
"objectId": "city_metro",
|
||||
"objectName": "地铁入口",
|
||||
"zulipTopic": "Transportation",
|
||||
"position": { "x": 600, "y": 550 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "offer_city",
|
||||
"mapName": "Offer 城",
|
||||
"zulipStream": "Offer City",
|
||||
"description": "职业发展、面试与商务区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "skyscrapers",
|
||||
"objectName": "摩天大楼",
|
||||
"zulipTopic": "Career Talk",
|
||||
"position": { "x": 350, "y": 650 }
|
||||
},
|
||||
{
|
||||
"objectId": "business_center",
|
||||
"objectName": "商务中心",
|
||||
"zulipTopic": "Interview Prep",
|
||||
"position": { "x": 300, "y": 700 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "model_factory",
|
||||
"mapName": "模型工厂",
|
||||
"zulipStream": "Model Factory",
|
||||
"description": "AI模型训练、代码构建与工业区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "assembly_line",
|
||||
"objectName": "流水线",
|
||||
"zulipTopic": "Code Review",
|
||||
"position": { "x": 400, "y": 200 }
|
||||
},
|
||||
{
|
||||
"objectId": "gear_tower",
|
||||
"objectName": "齿轮塔",
|
||||
"zulipTopic": "DevOps & CI/CD",
|
||||
"position": { "x": 450, "y": 180 }
|
||||
},
|
||||
{
|
||||
"objectId": "cable_car_station",
|
||||
"objectName": "缆车站",
|
||||
"zulipTopic": "Deployments",
|
||||
"position": { "x": 350, "y": 220 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "kernel_island",
|
||||
"mapName": "内核岛",
|
||||
"zulipStream": "Kernel Island",
|
||||
"description": "核心技术研究、底层原理与算法",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "crystal_core",
|
||||
"objectName": "能量水晶",
|
||||
"zulipTopic": "Core Algorithms",
|
||||
"position": { "x": 600, "y": 150 }
|
||||
},
|
||||
{
|
||||
"objectId": "floating_rocks",
|
||||
"objectName": "浮空石",
|
||||
"zulipTopic": "System Architecture",
|
||||
"position": { "x": 650, "y": 180 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "pumpkin_valley",
|
||||
"mapName": "南瓜谷",
|
||||
"zulipStream": "Pumpkin Valley",
|
||||
"description": "新手成长、基础资源与学习社区",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "pumpkin_patch",
|
||||
"objectName": "南瓜田",
|
||||
"zulipTopic": "Tutorials",
|
||||
"position": { "x": 150, "y": 400 }
|
||||
},
|
||||
{
|
||||
"objectId": "farm_house",
|
||||
"objectName": "农舍",
|
||||
"zulipTopic": "Study Group",
|
||||
"position": { "x": 200, "y": 450 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "moyu_beach",
|
||||
"mapName": "摸鱼海滩",
|
||||
"zulipStream": "Moyu Beach",
|
||||
"description": "休闲娱乐、水贴与非技术话题",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "beach_umbrella",
|
||||
"objectName": "遮阳伞",
|
||||
"zulipTopic": "Random Chat",
|
||||
"position": { "x": 850, "y": 200 }
|
||||
},
|
||||
{
|
||||
"objectId": "lighthouse",
|
||||
"objectName": "灯塔",
|
||||
"zulipTopic": "Music & Movies",
|
||||
"position": { "x": 800, "y": 100 }
|
||||
},
|
||||
{
|
||||
"objectId": "fishing_dock",
|
||||
"objectName": "栈桥",
|
||||
"zulipTopic": "Gaming",
|
||||
"position": { "x": 750, "y": 250 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "ladder_peak",
|
||||
"mapName": "天梯峰",
|
||||
"zulipStream": "Ladder Peak",
|
||||
"description": "挑战、竞赛与排行榜",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "summit_flag",
|
||||
"objectName": "峰顶旗帜",
|
||||
"zulipTopic": "Leaderboard",
|
||||
"position": { "x": 150, "y": 100 }
|
||||
},
|
||||
{
|
||||
"objectId": "snowy_path",
|
||||
"objectName": "雪径",
|
||||
"zulipTopic": "Challenges",
|
||||
"position": { "x": 200, "y": 150 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "galaxy_bay",
|
||||
"mapName": "星河湾",
|
||||
"zulipStream": "Galaxy Bay",
|
||||
"description": "创意、设计与灵感",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "starfish",
|
||||
"objectName": "巨型海星",
|
||||
"zulipTopic": "UI/UX Design",
|
||||
"position": { "x": 100, "y": 700 }
|
||||
},
|
||||
{
|
||||
"objectId": "palm_tree",
|
||||
"objectName": "椰子树",
|
||||
"zulipTopic": "Art & Assets",
|
||||
"position": { "x": 150, "y": 650 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"mapId": "data_ruins",
|
||||
"mapName": "数据遗迹",
|
||||
"zulipStream": "Data Ruins",
|
||||
"description": "数据库、归档与历史记录",
|
||||
"interactionObjects": [
|
||||
{
|
||||
"objectId": "ruined_gate",
|
||||
"objectName": "遗迹之门",
|
||||
"zulipTopic": "Database Schema",
|
||||
"position": { "x": 900, "y": 700 }
|
||||
},
|
||||
{
|
||||
"objectId": "ancient_monolith",
|
||||
"objectName": "石碑",
|
||||
"zulipTopic": "Archives",
|
||||
"position": { "x": 950, "y": 650 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1121
docs/ARCHITECTURE.md
1121
docs/ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
@@ -1,295 +0,0 @@
|
||||
# 架构重构文档
|
||||
|
||||
## 重构目标
|
||||
|
||||
将现有的混合架构重构为清晰的4层架构,实现更好的关注点分离和代码组织。
|
||||
|
||||
## 架构对比
|
||||
|
||||
### 重构前
|
||||
|
||||
```
|
||||
src/
|
||||
├── business/auth/ # 混合了Gateway和Business职责
|
||||
│ ├── login.controller.ts # HTTP协议处理
|
||||
│ ├── login.service.ts # 业务逻辑
|
||||
│ ├── jwt_auth.guard.ts # 认证守卫
|
||||
│ └── dto/ # 数据传输对象
|
||||
└── core/login_core/ # 核心层
|
||||
└── login_core.service.ts # 数据访问和基础设施
|
||||
```
|
||||
|
||||
### 重构后
|
||||
|
||||
```
|
||||
src/
|
||||
├── gateway/auth/ # 网关层(新增)
|
||||
│ ├── login.controller.ts # HTTP协议处理
|
||||
│ ├── register.controller.ts # HTTP协议处理
|
||||
│ ├── jwt_auth.guard.ts # 认证守卫
|
||||
│ ├── dto/ # 数据传输对象
|
||||
│ └── auth.gateway.module.ts # 网关模块
|
||||
├── business/auth/ # 业务层(精简)
|
||||
│ ├── login.service.ts # 登录业务逻辑
|
||||
│ ├── register.service.ts # 注册业务逻辑
|
||||
│ └── auth.module.ts # 业务模块
|
||||
└── core/login_core/ # 核心层(不变)
|
||||
└── login_core.service.ts # 数据访问和基础设施
|
||||
```
|
||||
|
||||
## 4层架构说明
|
||||
|
||||
### 1. Transport Layer(传输层)- 可选
|
||||
|
||||
**位置**:`src/transport/`
|
||||
|
||||
**职责**:
|
||||
- 底层网络通信和连接管理
|
||||
- WebSocket服务器、TCP/UDP服务器
|
||||
- 原生Socket连接池管理
|
||||
|
||||
**说明**:对于HTTP应用,NestJS已经提供了传输层,无需额外实现。对于WebSocket等特殊协议,可以在此层实现。
|
||||
|
||||
### 2. Gateway Layer(网关层)
|
||||
|
||||
**位置**:`src/gateway/`
|
||||
|
||||
**职责**:
|
||||
- HTTP协议处理和请求响应
|
||||
- 数据验证(DTO)
|
||||
- 路由管理
|
||||
- 认证守卫
|
||||
- 错误转换(业务错误 → HTTP状态码)
|
||||
- API文档
|
||||
|
||||
**原则**:
|
||||
- ✅ 只做协议转换,不做业务逻辑
|
||||
- ✅ 使用DTO进行数据验证
|
||||
- ✅ 统一的错误处理
|
||||
- ❌ 不直接访问数据库
|
||||
- ❌ 不包含业务规则
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
constructor(private readonly loginService: LoginService) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
|
||||
// 只做协议转换
|
||||
const result = await this.loginService.login({
|
||||
identifier: loginDto.identifier,
|
||||
password: loginDto.password
|
||||
});
|
||||
|
||||
// 转换为HTTP响应
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Business Layer(业务层)
|
||||
|
||||
**位置**:`src/business/`
|
||||
|
||||
**职责**:
|
||||
- 业务逻辑实现
|
||||
- 业务流程控制
|
||||
- 服务协调
|
||||
- 业务规则验证
|
||||
- 事务管理
|
||||
|
||||
**原则**:
|
||||
- ✅ 实现所有业务逻辑
|
||||
- ✅ 协调多个Core层服务
|
||||
- ✅ 返回统一的业务响应
|
||||
- ❌ 不处理HTTP协议
|
||||
- ❌ 不直接访问数据库
|
||||
|
||||
**示例**:
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
// 1. 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 2. 业务逻辑:验证Zulip账号
|
||||
await this.validateAndUpdateZulipApiKey(authResult.user);
|
||||
|
||||
// 3. 生成JWT令牌
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 4. 返回业务响应
|
||||
return {
|
||||
success: true,
|
||||
data: { user: this.formatUserInfo(authResult.user), ...tokenPair },
|
||||
message: '登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
error_code: 'LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Core Layer(核心层)
|
||||
|
||||
**位置**:`src/core/`
|
||||
|
||||
**职责**:
|
||||
- 数据访问(数据库、缓存)
|
||||
- 基础设施(Redis、消息队列)
|
||||
- 外部系统集成
|
||||
- 技术实现细节
|
||||
|
||||
**原则**:
|
||||
- ✅ 提供技术基础设施
|
||||
- ✅ 数据持久化和缓存
|
||||
- ✅ 外部API集成
|
||||
- ❌ 不包含业务逻辑
|
||||
- ❌ 不处理HTTP协议
|
||||
|
||||
## 数据流向
|
||||
|
||||
```
|
||||
客户端请求
|
||||
↓
|
||||
Gateway Layer (Controller)
|
||||
↓ 调用
|
||||
Business Layer (Service)
|
||||
↓ 调用
|
||||
Core Layer (Data Access)
|
||||
↓
|
||||
数据库/缓存/外部API
|
||||
```
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```
|
||||
Gateway → Business → Core
|
||||
```
|
||||
|
||||
- Gateway层依赖Business层
|
||||
- Business层依赖Core层
|
||||
- Core层不依赖任何业务层
|
||||
- 依赖方向单向,不允许反向依赖
|
||||
|
||||
## 重构步骤
|
||||
|
||||
### 第一阶段:登录注册模块(已完成)
|
||||
|
||||
1. ✅ 创建`src/gateway/auth/`目录
|
||||
2. ✅ 移动Controller到Gateway层
|
||||
3. ✅ 移动DTO到Gateway层
|
||||
4. ✅ 移动Guard到Gateway层
|
||||
5. ✅ 创建`AuthGatewayModule`
|
||||
6. ✅ 更新Business层模块,移除Controller
|
||||
7. ✅ 更新`app.module.ts`使用新的Gateway模块
|
||||
8. ✅ 创建架构文档
|
||||
|
||||
### 第二阶段:其他业务模块(待进行)
|
||||
|
||||
- [ ] 重构`location_broadcast`模块
|
||||
- [ ] 重构`user_mgmt`模块
|
||||
- [ ] 重构`admin`模块
|
||||
- [ ] 重构`zulip`模块
|
||||
- [ ] 重构`notice`模块
|
||||
|
||||
### 第三阶段:WebSocket模块(待进行)
|
||||
|
||||
- [ ] 创建`src/transport/websocket/`
|
||||
- [ ] 实现原生WebSocket服务器
|
||||
- [ ] 创建`src/gateway/location-broadcast/`
|
||||
- [ ] 移动WebSocket Gateway到Gateway层
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 如何判断代码应该放在哪一层?
|
||||
|
||||
**Gateway层**:
|
||||
- 包含`@Controller()`装饰器
|
||||
- 包含`@Get()`, `@Post()`等HTTP方法装饰器
|
||||
- 包含`@Body()`, `@Param()`, `@Query()`等参数装饰器
|
||||
- 包含DTO类(`class LoginDto`)
|
||||
- 包含Guard类(`class JwtAuthGuard`)
|
||||
|
||||
**Business层**:
|
||||
- 包含`@Injectable()`装饰器
|
||||
- 包含业务逻辑方法
|
||||
- 协调多个服务
|
||||
- 返回`ApiResponse<T>`格式的响应
|
||||
|
||||
**Core层**:
|
||||
- 包含数据库访问代码
|
||||
- 包含Redis操作代码
|
||||
- 包含外部API调用
|
||||
- 包含技术实现细节
|
||||
|
||||
### 重构Checklist
|
||||
|
||||
对于每个模块:
|
||||
|
||||
1. [ ] 识别Controller文件
|
||||
2. [ ] 创建对应的Gateway目录
|
||||
3. [ ] 移动Controller到Gateway层
|
||||
4. [ ] 移动DTO到Gateway层的`dto/`目录
|
||||
5. [ ] 移动Guard到Gateway层
|
||||
6. [ ] 创建Gateway Module
|
||||
7. [ ] 更新Business Module,移除Controller
|
||||
8. [ ] 更新imports,修正路径
|
||||
9. [ ] 更新app.module.ts
|
||||
10. [ ] 运行测试确保功能正常
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 保持层级职责清晰
|
||||
|
||||
每一层只做自己职责范围内的事情,不要越界。
|
||||
|
||||
### 2. 使用统一的响应格式
|
||||
|
||||
Business层返回统一的`ApiResponse<T>`格式:
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 错误处理分层
|
||||
|
||||
- Gateway层:将业务错误转换为HTTP状态码
|
||||
- Business层:捕获异常并转换为业务错误
|
||||
- Core层:抛出技术异常
|
||||
|
||||
### 4. 依赖注入
|
||||
|
||||
使用NestJS的依赖注入系统,通过Module配置依赖关系。
|
||||
|
||||
### 5. 文档完善
|
||||
|
||||
每个层级都应该有README文档说明职责和使用方法。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **渐进式重构**:不要一次性重构所有模块,逐个模块进行
|
||||
2. **保持测试**:重构后运行测试确保功能正常
|
||||
3. **向后兼容**:重构过程中保持API接口不变
|
||||
4. **代码审查**:重构代码需要经过代码审查
|
||||
5. **文档更新**:及时更新相关文档
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [NestJS官方文档](https://docs.nestjs.com/)
|
||||
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
- [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/)
|
||||
@@ -4,149 +4,116 @@
|
||||
|
||||
## 核心贡献者
|
||||
|
||||
### <EFBFBD> 项目维护者
|
||||
### 🏆 主要维护者
|
||||
|
||||
**moyin** - 项目维护者
|
||||
**moyin** - 主要维护者
|
||||
- Gitea: [@moyin](https://gitea.xinghangee.icu/moyin)
|
||||
- Email: xinghang_a@proton.me
|
||||
- 提交数: **166 commits** (不含合并提交)
|
||||
- 提交数: **112 commits**
|
||||
- 主要贡献:
|
||||
- 🚀 **项目架构设计** - 四层架构(Gateway-Business-Core-Data)设计与实现
|
||||
- <EFBFBD> **用户认证系统** - 完整的登录、注册、JWT认证、验证码登录
|
||||
- 📧 **邮箱验证系统** - 邮件服务、验证码服务、冷却时间机制
|
||||
- <EFBFBD>️ **双模式架构** - Redis缓存(文件/真实)、用户服务(内存/数据库)
|
||||
- <EFBFBD> **API文档系统** - Swagger UI、OpenAPI规范、WebSocket文档
|
||||
- 🧪 **测试框架** - Jest配置、507+测试用例、集成测试、E2E测试
|
||||
- <EFBFBD> **日志系统** - Pino高性能日志、结构化日志、日志管理
|
||||
- 🏗️ **架构重构** - Zulip模块重构、认证模块分层、安全模块迁移
|
||||
- 📚 **文档体系** - 架构文档、开发规范、AI代码检查指南、部署文档
|
||||
- 🎮 **游戏功能** - 位置广播系统、通知系统、地图房间管理
|
||||
- 🔧 **项目配置** - TypeScript配置、构建配置、环境配置、Docker部署
|
||||
- 🐛 **问题修复** - 验证码TTL重置、依赖注入、HTTP状态码、数据库管理
|
||||
- 🚀 项目架构设计与初始化
|
||||
- 🔐 完整用户认证系统实现
|
||||
- 📧 邮箱验证系统设计与开发
|
||||
- 🗄️ Redis缓存服务(文件存储+真实Redis双模式)
|
||||
- 📝 完整的API文档系统(Swagger UI + OpenAPI)
|
||||
- 🧪 测试框架搭建与507个测试用例编写
|
||||
- 📊 高性能日志系统集成(Pino)
|
||||
- 🔧 项目配置优化与部署方案
|
||||
- 🐛 验证码TTL重置关键问题修复
|
||||
- 📚 完整的项目文档体系建设
|
||||
- 🏗️ **Zulip模块架构重构** - 业务功能模块化架构设计与实现
|
||||
- 📖 **架构文档重写** - 详细的架构设计文档和开发者指南
|
||||
- 🔄 **验证码冷却时间优化** - 自动清除机制设计与实现
|
||||
- 📋 **文档清理优化** - 项目文档结构化整理和维护体系建立
|
||||
|
||||
### 🌟 核心开发者
|
||||
|
||||
**jianuo** - 核心开发者
|
||||
- Gitea: [@jianuo](https://gitea.xinghangee.icu/jianuo)
|
||||
- Email: 32106500027@e.gzhu.edu.cn
|
||||
- 提交数: **10 commits** (不含合并提交)
|
||||
- 主要贡献:
|
||||
- 🎛️ **管理员后台系统** - React前端界面、Ant Design组件、完整CRUD功能
|
||||
- 📊 **日志管理功能** - 运行时日志查看、日志下载、日志分析
|
||||
- <20> **管理员认证** - 独立Token认证、权限控制、会话管理
|
||||
- 🧪 **单元测试** - 管理员功能测试用例、测试覆盖率提升
|
||||
- ⚙️ **TypeScript配置** - Node16模块解析、编译配置优化
|
||||
- 🐳 **Docker部署** - 容器化部署问题修复、部署脚本优化
|
||||
- 📖 **文档维护** - 技术栈文档、部署文档、错误修复文档
|
||||
|
||||
**angjustinl** - 核心开发者
|
||||
- Gitea: [@ANGJustinl](https://gitea.xinghangee.icu/ANGJustinl)
|
||||
- GitHub: [@ANGJustinl](https://github.com/ANGJustinl)
|
||||
- Email: 96008766+ANGJustinl@users.noreply.github.com
|
||||
- 提交数: **9 commits** (不含合并提交)
|
||||
- 提交数: **7 commits**
|
||||
- 主要贡献:
|
||||
- <EFBFBD> **Zulip集成系统** - 完整的Zulip实时通信系统、WebSocket连接、消息同步
|
||||
- 🔑 **JWT认证重构** - JWT验证机制、API密钥管理、Token刷新
|
||||
- <EFBFBD> **邮箱验证重构** - 验证流程优化、内存用户服务、API响应改进
|
||||
- <EFBFBD> **验证码登录** - 验证码登录功能实现、测试用例编写
|
||||
- 🧪 **测试优化** - E2E测试修复、测试断言更新、测试覆盖完善
|
||||
- 🏗️ **Zulip账户管理** - Zulip账户创建、绑定、同步机制
|
||||
- 🔄 邮箱验证流程重构与优化
|
||||
- 💾 基于内存的用户服务实现
|
||||
- 🛠️ API响应处理改进
|
||||
- 🧪 测试用例完善与错误修复
|
||||
- 📚 系统架构优化
|
||||
- 💬 **Zulip集成系统** - 完整的Zulip实时通信系统开发
|
||||
- 🔧 **E2E测试修复** - Zulip集成的端到端测试优化
|
||||
- 🎯 **验证码登录测试** - 验证码登录功能测试用例编写
|
||||
|
||||
**jianuo** - 核心开发者
|
||||
- Gitea: [@jianuo](https://gitea.xinghangee.icu/jianuo)
|
||||
- Email: 32106500027@e.gzhu.edu.cn
|
||||
- 提交数: **11 commits**
|
||||
- 主要贡献:
|
||||
- 🎛️ **管理员后台系统** - 完整的前后端管理界面开发
|
||||
- 📊 **日志管理功能** - 运行时日志查看与下载系统
|
||||
- 🔐 **管理员认证系统** - 独立Token认证与权限控制
|
||||
- 🧪 **单元测试完善** - 管理员功能测试用例编写
|
||||
- ⚙️ **TypeScript配置优化** - Node16模块解析配置
|
||||
- 🐳 **Docker部署优化** - 容器化部署问题修复
|
||||
- 📖 **技术栈文档更新** - 项目技术栈说明完善
|
||||
- 🔧 **项目配置优化** - 构建和开发环境配置改进
|
||||
|
||||
## 贡献统计
|
||||
|
||||
| 贡献者 | 提交数 | 主要领域 | 贡献占比 |
|
||||
|--------|--------|----------|----------|
|
||||
| moyin | 166 | 架构设计、核心功能、文档、测试、重构 | 89.7% |
|
||||
| jianuo | 10 | 管理员后台、日志系统、部署优化 | 5.4% |
|
||||
| angjustinl | 9 | Zulip集成、JWT认证、验证码登录 | 4.9% |
|
||||
| moyin | 112 | 架构设计、核心功能、文档、测试、Zulip重构 | 86% |
|
||||
| jianuo | 11 | 管理员后台、日志系统、部署优化、配置管理 | 8% |
|
||||
| angjustinl | 7 | Zulip集成、功能优化、测试、重构 | 5% |
|
||||
|
||||
## 🌟 最新重要贡献
|
||||
|
||||
### 🏗️ 四层架构重构与规范化 (2026年1月)
|
||||
**主要贡献者**: moyin
|
||||
|
||||
项目完成了重大的架构升级和代码规范化工作:
|
||||
|
||||
- **认证模块重构** (1月14日): 将Gateway层组件从Business层分离,实现清晰的四层架构
|
||||
- **依赖注入优化** (1月14日): 修复AuthGatewayModule依赖注入问题,完善NestJS模块系统
|
||||
- **AI代码检查体系** (1月14日): 建立完整的AI辅助代码检查流程和规范文档
|
||||
- **架构文档完善** (1月14日): 新增架构重构文档、Gateway层规范、NestJS命名规范
|
||||
- **代码规范优化** (1月12日): 完善多个核心模块的代码规范和测试覆盖
|
||||
|
||||
### 📚 代码质量与测试提升 (2026年1月)
|
||||
**主要贡献者**: moyin
|
||||
|
||||
- **测试覆盖完善** (1月12日): 完善users、zulip、verification等模块测试覆盖
|
||||
- **文档体系建设** (1月12日): 添加开发者代码检查规范、AI代码检查执行指南
|
||||
- **性能优化** (1月12日): 集成高性能缓存系统和结构化日志
|
||||
- **模块功能扩展** (1月12日): 添加Zulip动态配置控制器和账户业务服务
|
||||
|
||||
### 🎮 游戏功能扩展 (2026年1月)
|
||||
**主要贡献者**: moyin
|
||||
|
||||
- **通知系统** (1月10日): 实现完整的通知系统核心功能和数据库支持
|
||||
- **WebSocket优化** (1月9日): 统一WebSocket网关配置、增强测试页面用户体验
|
||||
- **原生WebSocket** (1月9日): 移除Socket.IO依赖,实现原生WebSocket支持
|
||||
- **位置广播系统** (1月8日): 实现位置广播系统和端到端测试
|
||||
- **管理员系统** (1月8日): 完善管理员系统核心功能和用户管理模块
|
||||
|
||||
### 🏗️ Zulip模块架构重构 (2025年12月)
|
||||
### 🏗️ Zulip模块架构重构 (2025年12月31日)
|
||||
**主要贡献者**: moyin, angjustinl
|
||||
|
||||
- **架构重构** (12月31日): 实现业务功能模块化架构,清晰分离业务层和核心层
|
||||
- **Zulip集成** (12月25日): angjustinl开发完整的Zulip实时通信系统
|
||||
- **JWT认证** (1月6日): angjustinl引入JWT验证并重构API密钥管理
|
||||
- **账户管理** (1月5日): angjustinl添加Zulip账户管理和认证系统集成
|
||||
这是项目历史上最重要的架构重构之一:
|
||||
|
||||
- **架构重构**: 实现业务功能模块化架构,将Zulip模块按照业务层和核心层进行清晰分离
|
||||
- **代码迁移**: 36个文件的重构和迁移,涉及2773行代码的新增和125行的删除
|
||||
- **依赖注入**: 通过接口抽象实现业务层与核心层的完全解耦
|
||||
- **测试完善**: 所有507个测试用例通过,确保重构的安全性
|
||||
|
||||
### 📚 项目文档体系优化 (2025年12月31日)
|
||||
**主要贡献者**: moyin
|
||||
|
||||
- **架构文档重写**: `docs/ARCHITECTURE.md` 从简单架构图扩展为800+行的完整架构设计文档
|
||||
- **README优化**: 采用总分结构设计,详细的文件结构总览
|
||||
- **文档清理**: 新增 `docs/DOCUMENT_CLEANUP.md` 记录文档维护过程
|
||||
- **开发者体验**: 建立完整的文档导航体系,提升开发者上手体验
|
||||
|
||||
### 💬 Zulip集成系统 (2025年12月25日)
|
||||
**主要贡献者**: angjustinl
|
||||
|
||||
- **完整集成**: 实现与Zulip的完整集成,支持实时通信功能
|
||||
- **WebSocket支持**: 建立稳定的WebSocket连接和消息处理机制
|
||||
- **测试覆盖**: 完善的E2E测试确保集成功能的稳定性
|
||||
|
||||
## 项目里程碑
|
||||
|
||||
### 2026年1月
|
||||
- **1月14日**: 🏗️ 认证模块四层架构重构,Gateway层与Business层清晰分离
|
||||
- **1月14日**: 🔧 修复AuthGatewayModule依赖注入问题,完善模块系统
|
||||
- **1月14日**: 📚 建立AI代码检查体系,添加完整的规范文档
|
||||
- **1月14日**: 📖 新增架构重构文档和NestJS框架规范说明
|
||||
- **1月12日**: ✨ 完善多个核心模块的代码规范和测试覆盖
|
||||
- **1月12日**: 🧪 添加Zulip业务模块完整测试覆盖
|
||||
- **1月12日**: 📝 添加开发者代码检查规范和AI检查执行指南
|
||||
- **1月12日**: ⚡ 集成高性能缓存系统和结构化日志
|
||||
- **1月10日**: 🔔 实现通知系统核心功能和数据库支持
|
||||
- **1月10日**: 🐛 修复数据库管理服务的关键问题
|
||||
- **1月9日**: 🌐 统一WebSocket网关配置,增强测试页面
|
||||
- **1月9日**: 🔄 移除Socket.IO依赖,实现原生WebSocket支持
|
||||
- **1月8日**: 📍 实现位置广播系统和端到端测试
|
||||
- **1月8日**: 👑 完善管理员系统核心功能
|
||||
- **1月8日**: 🏗️ 项目架构重构和命名规范化
|
||||
- **1月7日**: 📦 升级到v2.0.0版本
|
||||
- **1月6日**: 🔑 angjustinl引入JWT验证并重构API密钥管理
|
||||
- **1月5日**: 👤 angjustinl添加Zulip账户管理和认证系统集成
|
||||
- **1月4日**: 🛡️ 重构安全模块架构,迁移至core层
|
||||
|
||||
### 2025年12月
|
||||
- **12月31日**: 🏗️ Zulip模块业务功能模块化架构重构
|
||||
- **12月31日**: 📚 项目文档结构化整理和架构文档重写
|
||||
- **12月25日**: 💬 angjustinl开发完整的Zulip集成系统
|
||||
- **12月25日**: 🔄 实现验证码冷却时间自动清除机制
|
||||
- **12月25日**: 📧 完成邮箱冲突检测优化v1.1.1
|
||||
- **12月25日**: 🎯 angjustinl实现验证码登录功能
|
||||
- **12月25日**: 📈 升级项目版本到v1.1.0
|
||||
- **12月24日**: 🐛 修复注册逻辑和HTTP状态码问题
|
||||
- **12月24日**: 🔧 修复API状态码和限流配置问题
|
||||
- **12月24日**: 🏗️ 重构项目结构和业务模块架构
|
||||
- **12月23日**: 📖 全面更新API接口文档
|
||||
- **12月22日**: 🎛️ jianuo的管理员后台功能合并到主分支
|
||||
- **12月19日**: 👑 jianuo开发管理员后台系统
|
||||
- **12月19日**: 📊 jianuo完善日志管理功能
|
||||
- **12月19日**: 🧪 jianuo添加管理员后台单元测试
|
||||
- **12月19日**: ⚙️ jianuo优化TypeScript配置
|
||||
- **12月18日**: 🔄 angjustinl重构邮箱验证流程,引入内存用户服务
|
||||
- **12月18日**: 🐳 jianuo修复Docker部署问题
|
||||
- **12月18日**: 🧪 完成测试用例修复和优化
|
||||
- **12月17日**: 🐛 修复验证码TTL重置关键问题
|
||||
- **12月17日**: 📧 实现完整的邮箱验证系统
|
||||
- **12月17日**: 🗄️ 实现Redis缓存服务(双模式)
|
||||
- **12月17日**: 📝 完成API文档系统集成
|
||||
- **12月17日**: 🔐 实现完整的用户认证系统
|
||||
- **12月17日**: 🚀 项目初始化,完成基础架构搭建
|
||||
- **12月17日**: 项目初始化,完成基础架构搭建
|
||||
- **12月17日**: 实现完整的用户认证系统
|
||||
- **12月17日**: 完成API文档系统集成
|
||||
- **12月17日**: 实现邮箱验证系统
|
||||
- **12月17日**: 修复验证码TTL重置关键问题
|
||||
- **12月18日**: angjustinl重构邮箱验证流程,引入内存用户服务
|
||||
- **12月18日**: jianuo修复Docker部署问题
|
||||
- **12月18日**: 完成测试用例修复和优化
|
||||
- **12月19日**: jianuo开发管理员后台系统
|
||||
- **12月20日**: jianuo完善日志管理功能
|
||||
- **12月21日**: jianuo添加管理员后台单元测试
|
||||
- **12月22日**: 管理员后台功能合并到主分支
|
||||
- **12月25日**: angjustinl开发完整的Zulip集成系统
|
||||
- **12月25日**: 实现验证码冷却时间自动清除机制
|
||||
- **12月25日**: 完成邮箱冲突检测优化v1.1.1
|
||||
- **12月25日**: 升级项目版本到v1.1.0
|
||||
- **12月31日**: **重大架构重构** - 完成Zulip模块业务功能模块化架构重构
|
||||
- **12月31日**: **文档体系优化** - 项目文档结构化整理和架构文档重写
|
||||
- **12月31日**: **测试覆盖完善** - 所有507个测试用例通过,测试覆盖率达到新高
|
||||
|
||||
## 如何成为贡献者
|
||||
|
||||
@@ -170,13 +137,12 @@
|
||||
### 贡献规范
|
||||
|
||||
请在贡献前阅读:
|
||||
- [开发者代码检查规范](./开发者代码检查规范.md)
|
||||
- [后端开发规范](./development/backend_development_guide.md)
|
||||
- [Git提交规范](./development/git_commit_guide.md)
|
||||
- [AI代码检查指南](./ai-reading/README.md)
|
||||
- [AI辅助开发规范指南](./docs/AI辅助开发规范指南.md)
|
||||
- [后端开发规范](./docs/backend_development_guide.md)
|
||||
- [Git提交规范](./docs/git_commit_guide.md)
|
||||
|
||||
---
|
||||
|
||||
**再次感谢所有贡献者的辛勤付出!** 🙏
|
||||
|
||||
*如果你的名字没有出现在列表中,请联系我们或提交PR更新此文件。*
|
||||
*如果你的名字没有出现在列表中,请联系我们或提交PR更新此文件。*
|
||||
@@ -1,397 +0,0 @@
|
||||
# AI Code Inspection Guide - Whale Town Game Server
|
||||
|
||||
## 🎯 Pre-execution Setup
|
||||
|
||||
### 🚀 User Information Setup
|
||||
**Before starting any inspection steps, run the user information script:**
|
||||
|
||||
```bash
|
||||
# Enter AI-reading directory
|
||||
cd docs/ai-reading
|
||||
|
||||
# Run user information setup script
|
||||
node tools/setup-user-info.js
|
||||
```
|
||||
|
||||
#### Script Functions
|
||||
- Automatically get current date (YYYY-MM-DD format)
|
||||
- Check if config file exists or date matches
|
||||
- Prompt for username/nickname input if needed
|
||||
- Save to `me.config.json` file for AI inspection steps
|
||||
|
||||
#### Config File Format
|
||||
```json
|
||||
{
|
||||
"date": "2026-01-13",
|
||||
"name": "Developer Name"
|
||||
}
|
||||
```
|
||||
|
||||
### 📋 Using Config in AI Inspection Steps
|
||||
When AI executes inspection steps, get user info from config file:
|
||||
|
||||
```javascript
|
||||
// Read config file
|
||||
const fs = require('fs');
|
||||
const config = JSON.parse(fs.readFileSync('docs/ai-reading/me.config.json', 'utf-8'));
|
||||
|
||||
// Get user information
|
||||
const userDate = config.date; // e.g.: "2026-01-13"
|
||||
const userName = config.name; // e.g.: "John"
|
||||
|
||||
// Use for modification records and @author fields
|
||||
const modifyRecord = `- ${userDate}: Code standard optimization - Clean unused imports (Modified by: ${userName})`;
|
||||
```
|
||||
|
||||
### 🏗️ Project Characteristics
|
||||
This project is a **NestJS Game Server** with the following features:
|
||||
- **Dual-mode Architecture**: Supports both database and memory modes
|
||||
- **Real-time Communication**: WebSocket-based real-time bidirectional communication
|
||||
- **Property Testing**: Admin modules use fast-check for randomized testing
|
||||
- **Layered Architecture**: Core layer (technical implementation) + Business layer (business logic)
|
||||
|
||||
## 🔄 Execution Principles
|
||||
|
||||
### 🚨 Mid-step Start Requirements (Important)
|
||||
**If AI starts execution from any intermediate step (not starting from step 1), must first complete the following preparation:**
|
||||
|
||||
#### 📋 Mandatory Information Collection
|
||||
Before executing any intermediate step, AI must:
|
||||
1. **Collect user current date**: For modification records and timestamp updates
|
||||
2. **Collect user name**: For @author field handling and modification records
|
||||
3. **Confirm project characteristics**: Identify NestJS game server project features
|
||||
|
||||
#### 🔍 Global Context Acquisition
|
||||
AI must first understand:
|
||||
- **Project Architecture**: Dual-mode architecture (database+memory), layered structure (Core+Business)
|
||||
- **Tech Stack**: NestJS, WebSocket, Jest testing, fast-check property testing
|
||||
- **File Structure**: Overall file organization of current project
|
||||
- **Existing Standards**: Established naming, commenting, testing standards in project
|
||||
|
||||
#### 🎯 Execution Flow Constraints
|
||||
```
|
||||
Mid-step Start Request
|
||||
↓
|
||||
🚨 Mandatory User Info Collection (date, name)
|
||||
↓
|
||||
🚨 Mandatory Project Characteristics & Context Identification
|
||||
↓
|
||||
🚨 Mandatory Understanding of Target Step Requirements
|
||||
↓
|
||||
Start Executing Specified Step
|
||||
```
|
||||
|
||||
**⚠️ Violation Handling: If AI skips information collection and directly executes intermediate steps, user should require AI to restart and complete preparation work.**
|
||||
|
||||
### ⚠️ Mandatory Requirements
|
||||
- **Step-by-step Execution**: Execute one step at a time, strictly no step skipping or merging
|
||||
- **Wait for Confirmation**: Must wait for user confirmation after each step before proceeding
|
||||
- **Modification Verification**: Must re-execute current step after any file modification
|
||||
- **🔥 Must Re-execute Current Step After Modification**: If any modification behavior occurs during current step (file modification, renaming, moving, etc.), AI must immediately re-execute the complete check of that step, cannot directly proceed to next step
|
||||
- **Re-check After Problem Fix**: If current step has problems requiring modification, AI must re-execute the step after solving problems to ensure no other issues are missed
|
||||
- **User Info Usage**: All date fields use user-provided real dates, @author fields handled correctly
|
||||
|
||||
### 🎯 Execution Flow
|
||||
```
|
||||
User Requests Code Inspection
|
||||
↓
|
||||
Collect User Info (date, name)
|
||||
↓
|
||||
Identify Project Characteristics
|
||||
↓
|
||||
Execute Step 1 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 1 → Verification Report → Wait for Confirmation
|
||||
↓
|
||||
Execute Step 2 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 2 → Verification Report → Wait for Confirmation
|
||||
↓
|
||||
Execute Step 3 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 3 → Verification Report → Wait for Confirmation
|
||||
↓
|
||||
Execute Step 4 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 4 → Verification Report → Wait for Confirmation
|
||||
↓
|
||||
Execute Step 5 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 5 → Verification Report → Wait for Confirmation
|
||||
↓
|
||||
Execute Step 6 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 6 → Verification Report → Wait for Confirmation
|
||||
↓
|
||||
Execute Step 7 → Provide Report → Wait for Confirmation
|
||||
↓
|
||||
[If Modification Occurs] → 🔥 Immediately Re-execute Step 7 → Verification Report → Wait for Confirmation
|
||||
|
||||
⚠️ Key Rule: After any modification behavior in any step, must immediately re-execute that step!
|
||||
```
|
||||
|
||||
## 📚 Step Execution Guide
|
||||
|
||||
### Step 1: Naming Convention Check
|
||||
**Read when executing:** `step1-naming-convention.md`
|
||||
**Focus on:** Folder structure flattening, game server special file types
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
### Step 2: Comment Standard Check
|
||||
**Read when executing:** `step2-comment-standard.md`
|
||||
**Focus on:** @author field handling, modification record updates, timestamp rules
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
### Step 3: Code Quality Check
|
||||
**Read when executing:** `step3-code-quality.md`
|
||||
**Focus on:** TODO item handling, unused code cleanup
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
### Step 4: Architecture Layer Check
|
||||
**Read when executing:** `step4-architecture-layer.md`
|
||||
**Focus on:** Core layer naming standards, dependency relationship checks
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
### Step 5: Test Coverage Check
|
||||
**Read when executing:** `step5-test-coverage.md`
|
||||
**Focus on:** Strict one-to-one test mapping, test file locations, test execution verification
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
#### 🧪 Test File Debugging Standards
|
||||
**When debugging test files, must follow this workflow:**
|
||||
|
||||
1. **Read jest.config.js Configuration**
|
||||
- Check jest.config.js to understand test environment configuration
|
||||
- Confirm testRegex patterns and file matching rules
|
||||
- Understand moduleNameMapper and other configuration items
|
||||
|
||||
2. **Use Existing Test Commands in package.json**
|
||||
- **Forbidden to customize jest commands**: Must use test commands defined in package.json scripts
|
||||
- **Common Test Commands**:
|
||||
- `npm run test` - Run all tests
|
||||
- `npm run test:unit` - Run unit tests (.spec.ts files)
|
||||
- `npm run test:integration` - Run integration tests (.integration.spec.ts files)
|
||||
- `npm run test:e2e` - Run end-to-end tests (.e2e.spec.ts files)
|
||||
- `npm run test:watch` - Run tests in watch mode
|
||||
- `npm run test:cov` - Run tests and generate coverage report
|
||||
- `npm run test:debug` - Run tests in debug mode
|
||||
- `npm run test:isolated` - Run tests in isolation
|
||||
|
||||
3. **Specific Module Test Commands**
|
||||
- **Zulip Module Tests**:
|
||||
- `npm run test:zulip` - Run all Zulip-related tests
|
||||
- `npm run test:zulip:unit` - Run Zulip unit tests
|
||||
- `npm run test:zulip:integration` - Run Zulip integration tests
|
||||
- `npm run test:zulip:e2e` - Run Zulip end-to-end tests
|
||||
- `npm run test:zulip:performance` - Run Zulip performance tests
|
||||
|
||||
4. **Test Execution Verification Workflow**
|
||||
```
|
||||
Discover Test Issue → Read jest.config.js → Choose Appropriate npm run test:xxx Command → Execute Test → Analyze Results → Fix Issues → Re-execute Test
|
||||
```
|
||||
|
||||
5. **Test Command Selection Principles**
|
||||
- **Single File Test**: Use `npm run test -- file_path`
|
||||
- **Specific Type Test**: Use corresponding test:xxx command
|
||||
- **Debug Test**: Prioritize `npm run test:debug`
|
||||
- **CI/CD Environment**: Use `npm run test:isolated`
|
||||
|
||||
### Step 6: Function Documentation Generation
|
||||
**Read when executing:** `step6-documentation.md`
|
||||
**Focus on:** API interface documentation, WebSocket event documentation
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
### Step 7: Code Commit
|
||||
**Read when executing:** `step7-code-commit.md`
|
||||
**Focus on:** Git change verification, modification record consistency check, standardized commit process
|
||||
**After completion:** Provide inspection report, wait for user confirmation
|
||||
|
||||
## 📋 Unified Report Template
|
||||
|
||||
Use this template for reporting after each step completion:
|
||||
|
||||
```
|
||||
## Step X: [Step Name] Inspection Report
|
||||
|
||||
### 🔍 Inspection Results
|
||||
[List of discovered issues]
|
||||
|
||||
### 🛠️ Correction Plan
|
||||
[Specific correction suggestions]
|
||||
|
||||
### ✅ Completion Status
|
||||
- Check Item 1 ✓/✗
|
||||
- Check Item 2 ✓/✗
|
||||
|
||||
**Please confirm correction plan, proceed to next step after confirmation**
|
||||
```
|
||||
|
||||
## 🚨 Global Constraints
|
||||
|
||||
### 📝 File Modification Record Standards (Important)
|
||||
**After each modification execution, file headers need to update modification records and related information**
|
||||
|
||||
#### Modification Type Definitions
|
||||
- `Code Standard Optimization` - Naming standards, comment standards, code cleanup, etc.
|
||||
- `Feature Addition` - Adding new features or methods
|
||||
- `Feature Modification` - Modifying existing feature implementations
|
||||
- `Bug Fix` - Fixing code defects
|
||||
- `Performance Optimization` - Improving code performance
|
||||
- `Refactoring` - Code structure adjustment but functionality unchanged
|
||||
|
||||
#### Modification Record Format Requirements
|
||||
```typescript
|
||||
/**
|
||||
* Recent Modifications:
|
||||
* - [User Date]: Code Standard Optimization - Clean unused imports (Modified by: [User Name])
|
||||
* - 2024-01-06: Bug Fix - Fix email validation logic error (Modified by: Li Si)
|
||||
* - 2024-01-05: Feature Addition - Add user verification code login feature (Modified by: Wang Wu)
|
||||
*
|
||||
* @author [Processed Author Name]
|
||||
* @version x.x.x
|
||||
* @since [Creation Date]
|
||||
* @lastModified [User Date]
|
||||
*/
|
||||
```
|
||||
|
||||
#### 🔢 Recent Modification Record Quantity Limit
|
||||
- **Maximum 5 Records**: Recent modification records keep maximum of 5 latest records
|
||||
- **Auto-delete When Exceeded**: When adding new modification records, if exceeding 5, automatically delete oldest records
|
||||
- **Maintain Time Order**: Records arranged in reverse chronological order, newest at top
|
||||
- **Complete Record Retention**: Each record must include complete date, modification type, description and modifier information
|
||||
|
||||
#### Version Number Increment Rules
|
||||
- **Patch Version +1**: Code standard optimization, bug fixes (1.0.0 → 1.0.1)
|
||||
- **Minor Version +1**: Feature addition, feature modification (1.0.1 → 1.1.0)
|
||||
- **Major Version +1**: Refactoring, architecture changes (1.1.0 → 2.0.0)
|
||||
|
||||
#### Time Update Rules
|
||||
- **Check Only No Modification**: If only checking without actually modifying file content, **do not update** @lastModified field
|
||||
- **Update Only on Actual Modification**: Only update @lastModified field and add modification records when actually modifying file content
|
||||
- **Git Change Detection**: Check if files have actual changes through `git status` and `git diff`, only add modification records and update timestamps when git shows files are modified
|
||||
|
||||
#### 🚨 Important Emphasis: Pure Check Steps Do Not Update Modification Records
|
||||
**When AI executes code inspection steps, if code already meets standards and needs no modification, then:**
|
||||
- **Forbidden to Add Modification Records**: Do not add records like "AI code inspection step X: XXX check and optimization"
|
||||
- **Forbidden to Update Timestamps**: Do not update @lastModified field
|
||||
- **Forbidden to Increment Version Numbers**: Do not modify @version field
|
||||
- **Only add modification records when actually modifying code content, comment content, structure, etc.**
|
||||
|
||||
**Wrong Example**:
|
||||
```typescript
|
||||
// ❌ Wrong: Only checked without modification but added modification record
|
||||
/**
|
||||
* Recent Modifications:
|
||||
* - 2026-01-12: Code Standard Optimization - AI code inspection step 2: Comment standard check and optimization (Modified by: moyin) // This is wrong!
|
||||
* - 2026-01-07: Feature Addition - Add user verification feature (Modified by: Zhang San)
|
||||
*/
|
||||
```
|
||||
|
||||
**Correct Example**:
|
||||
```typescript
|
||||
// ✅ Correct: Check found compliance with standards, do not add modification records
|
||||
/**
|
||||
* Recent Modifications:
|
||||
* - 2026-01-07: Feature Addition - Add user verification feature (Modified by: Zhang San) // Keep original records unchanged
|
||||
*/
|
||||
```
|
||||
|
||||
### @author Field Handling Standards
|
||||
- **Retention Principle**: Human names must be retained, cannot be arbitrarily modified
|
||||
- **AI Identifier Replacement**: Only AI identifiers (kiro, ChatGPT, Claude, AI, etc.) can be replaced with user names
|
||||
- **Judgment Example**: `@author kiro` → Can replace, `@author Zhang San` → Must retain
|
||||
|
||||
### Game Server Special Requirements
|
||||
- **WebSocket Files**: Gateway files must have complete connection and message processing tests
|
||||
- **Dual-mode Services**: Both memory services and database services need complete test coverage
|
||||
- **Property Testing**: Admin modules use fast-check for property testing
|
||||
- **Test Separation**: Strictly distinguish unit tests, integration tests, E2E tests, performance tests
|
||||
|
||||
## 🔧 Modification Verification Process
|
||||
|
||||
### 🔥 Immediately Re-execute Rule After Modification (Important)
|
||||
**After any modification behavior occurs in any step, AI must immediately re-execute that step, cannot directly proceed to next step!**
|
||||
|
||||
#### Modification Behaviors Include But Not Limited To:
|
||||
- File content modification (code, comments, configuration, etc.)
|
||||
- File renaming
|
||||
- File moving
|
||||
- File deletion
|
||||
- New file creation
|
||||
- Folder structure adjustment
|
||||
|
||||
#### Mandatory Execution Process:
|
||||
```
|
||||
Step Execution → Discover Issues → Execute Modifications → 🔥 Immediately Re-execute That Step → Verify No Omissions → User Confirmation → Next Step
|
||||
```
|
||||
|
||||
### Re-check Process After Problem Fix
|
||||
When issues are discovered and modifications made in any step, must follow this process:
|
||||
|
||||
1. **Execute Modification Operations**
|
||||
- Make specific modifications based on discovered issues
|
||||
- Ensure modification content is accurate
|
||||
- **Update file header modification records, version numbers and @lastModified fields**
|
||||
|
||||
2. **🔥 Immediately Re-execute Current Step**
|
||||
- **Cannot skip this step!**
|
||||
- Complete re-execution of all check items for that step
|
||||
- Cannot only check modified parts, must comprehensively re-check
|
||||
|
||||
3. **Provide Verification Report**
|
||||
- Confirm previously discovered issues are resolved
|
||||
- Confirm no new issues introduced
|
||||
- Confirm no other issues omitted
|
||||
|
||||
4. **Wait for User Confirmation**
|
||||
- Provide complete verification report
|
||||
- Wait for user confirmation before proceeding to next step
|
||||
|
||||
### Verification Report Template
|
||||
```
|
||||
## Step X: Modification Verification Report
|
||||
|
||||
### 🔧 Executed Modification Operations
|
||||
- Modification Type: [File modification/renaming/moving/deletion, etc.]
|
||||
- Modification Content: [Specific modification description]
|
||||
- Affected Files: [List of affected files]
|
||||
|
||||
### 📝 Updated Modification Records
|
||||
- Added Modification Record: [User Date]: [Modification Type] - [Modification Content] (Modified by: [User Name])
|
||||
- Updated Version Number: [Old Version] → [New Version]
|
||||
- Updated Timestamp: @lastModified [User Date]
|
||||
|
||||
### 🔍 Re-executed Step X Complete Check Results
|
||||
[Complete re-execution results of all check items for that step]
|
||||
|
||||
### ✅ Verification Status
|
||||
- Original Issues Resolved ✓
|
||||
- Modification Records Updated ✓
|
||||
- No New Issues Introduced ✓
|
||||
- No Other Issues Omitted ✓
|
||||
- Step X Check Completely Passed ✓
|
||||
|
||||
**🔥 Important: This step has completed modification and re-verification, please confirm before proceeding to next step**
|
||||
```
|
||||
|
||||
### Importance of Re-checking
|
||||
- **Ensure Completeness**: Avoid omitting other issues during modification process
|
||||
- **Prevent New Issues**: Ensure modifications do not introduce new problems
|
||||
- **Maintain Quality**: Each step reaches complete inspection standards
|
||||
- **Maintain Consistency**: Ensure rigor throughout entire inspection process
|
||||
- **🔥 Mandatory Execution**: Cannot skip this step after modifications
|
||||
|
||||
## ⚡ Key Success Factors
|
||||
|
||||
- **Strict Step-by-step Execution**: No step skipping, no merged execution
|
||||
- **🔥 Immediately Re-execute After Modification**: Must immediately re-execute current step after any modification behavior, cannot directly proceed to next step
|
||||
- **Must Re-check After Problem Fix**: Must re-execute entire step after file modification to ensure no omissions
|
||||
- **Must Update Modification Records**: Must update file header modification records, version numbers and timestamps after each file modification
|
||||
- **Real Modification Verification**: Verify modification effects through tools
|
||||
- **Accurate User Info Usage**: Correctly apply date and name information
|
||||
- **Project Characteristic Adaptation**: Optimize inspections for game server characteristics
|
||||
- **Complete Report Provision**: Provide detailed inspection reports for each step
|
||||
|
||||
---
|
||||
|
||||
**Before starting execution, please first run `node tools/setup-user-info.js` to set user information!**
|
||||
@@ -1,251 +0,0 @@
|
||||
# 步骤1:命名规范检查
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
检查和修正所有命名规范问题,确保项目代码命名一致性。
|
||||
|
||||
## 📋 命名规范标准
|
||||
|
||||
### 文件和文件夹命名
|
||||
|
||||
#### 🚨 NestJS 框架文件命名规范(重要)
|
||||
**本项目使用 NestJS 框架,框架相关文件命名规则:**
|
||||
|
||||
**命名组成 = 文件名(snake_case) + 类型标识符(点分隔) + 扩展名**
|
||||
|
||||
```
|
||||
✅ 正确的 NestJS 文件命名:
|
||||
- login.controller.ts # 单词文件名 + .controller
|
||||
- user_profile.service.ts # snake_case文件名 + .service
|
||||
- auth_core.module.ts # snake_case文件名 + .module
|
||||
- login_request.dto.ts # snake_case文件名 + .dto
|
||||
- jwt_auth.guard.ts # snake_case文件名 + .guard
|
||||
- current_user.decorator.ts # snake_case文件名 + .decorator
|
||||
- user_profile.controller.spec.ts # snake_case文件名 + .controller.spec
|
||||
|
||||
❌ 错误的命名示例:
|
||||
- loginController.ts # 错误!应该是 login.controller.ts
|
||||
- user-profile.service.ts # 错误!应该是 user_profile.service.ts
|
||||
- authCore.module.ts # 错误!应该是 auth_core.module.ts
|
||||
- login_controller.ts # 错误!类型标识符应该用点分隔,不是下划线
|
||||
```
|
||||
|
||||
**关键规则:**
|
||||
1. **文件名部分**:使用 snake_case(如 `user_profile`、`auth_core`)
|
||||
2. **类型标识符**:使用点分隔(如 `.controller`、`.service`)
|
||||
3. **完整格式**:`文件名.类型标识符.ts`(如 `user_profile.service.ts`)
|
||||
|
||||
**NestJS 文件类型标识符(必须使用点分隔):**
|
||||
- `.controller.ts` - 控制器(如 `user_auth.controller.ts`)
|
||||
- `.service.ts` - 服务(如 `user_profile.service.ts`)
|
||||
- `.module.ts` - 模块(如 `auth_core.module.ts`)
|
||||
- `.dto.ts` - 数据传输对象(如 `login_request.dto.ts`)
|
||||
- `.entity.ts` - 实体(如 `user_account.entity.ts`)
|
||||
- `.interface.ts` - 接口(如 `game_config.interface.ts`)
|
||||
- `.guard.ts` - 守卫(如 `jwt_auth.guard.ts`)
|
||||
- `.interceptor.ts` - 拦截器(如 `response_transform.interceptor.ts`)
|
||||
- `.pipe.ts` - 管道(如 `validation_pipe.pipe.ts`)
|
||||
- `.filter.ts` - 过滤器(如 `http_exception.filter.ts`)
|
||||
- `.decorator.ts` - 装饰器(如 `current_user.decorator.ts`)
|
||||
- `.middleware.ts` - 中间件(如 `logger_middleware.middleware.ts`)
|
||||
- `.spec.ts` - 单元测试(如 `user_profile.service.spec.ts`)
|
||||
- `.e2e.spec.ts` - E2E 测试(如 `auth_flow.e2e.spec.ts`)
|
||||
|
||||
**命名规则说明:**
|
||||
1. **文件名使用 snake_case**:多个单词用下划线连接(如 `user_profile`、`auth_core`)
|
||||
2. **类型标识符使用点分隔**:遵循 NestJS/Angular 风格(如 `.controller`、`.service`)
|
||||
3. **组合格式**:`snake_case文件名.类型标识符.ts`
|
||||
4. **社区标准**:这是本项目结合 NestJS 规范和 snake_case 约定的标准做法
|
||||
|
||||
#### 普通文件和文件夹命名
|
||||
- **规则**:snake_case(下划线分隔),保持项目一致性
|
||||
- **适用范围**:非 NestJS 框架文件、工具类、配置文件、普通文件夹等
|
||||
- **示例**:
|
||||
```
|
||||
✅ 正确:user_utils.ts, admin_operation_log.ts, config_loader.ts
|
||||
❌ 错误:UserUtils.ts, user-utils.ts, adminOperationLog.ts
|
||||
```
|
||||
|
||||
### 变量和函数命名
|
||||
- **规则**:camelCase(小驼峰命名)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:const userName = 'test'; function getUserInfo() {}
|
||||
❌ 错误:const UserName = 'test'; function GetUserInfo() {}
|
||||
```
|
||||
|
||||
### 类和接口命名
|
||||
- **规则**:PascalCase(大驼峰命名)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:class UserService {} interface GameConfig {}
|
||||
❌ 错误:class userService {} interface gameConfig {}
|
||||
```
|
||||
|
||||
### 常量命名
|
||||
- **规则**:SCREAMING_SNAKE_CASE(全大写+下划线)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:const MAX_RETRY_COUNT = 3; const SALT_ROUNDS = 10;
|
||||
❌ 错误:const maxRetryCount = 3; const saltRounds = 10;
|
||||
```
|
||||
|
||||
### 路由命名
|
||||
- **规则**:kebab-case(短横线分隔)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:@Get('user/get-info') @Post('room/join-room')
|
||||
❌ 错误:@Get('user/getInfo') @Post('room/joinRoom')
|
||||
```
|
||||
|
||||
## 🎮 游戏服务器特殊文件类型
|
||||
|
||||
### WebSocket相关文件
|
||||
```
|
||||
✅ 正确命名:
|
||||
- location_broadcast.gateway.ts # WebSocket网关
|
||||
- websocket_auth.guard.ts # WebSocket认证守卫
|
||||
- realtime_chat.service.ts # 实时通信服务
|
||||
```
|
||||
|
||||
### 双模式服务文件
|
||||
```
|
||||
✅ 正确命名:
|
||||
- users_memory.service.ts # 内存模式服务
|
||||
- users_database.service.ts # 数据库模式服务
|
||||
- file_redis.service.ts # Redis文件存储
|
||||
```
|
||||
|
||||
### 测试文件分类
|
||||
```
|
||||
✅ 正确命名:
|
||||
- user.service.spec.ts # 单元测试
|
||||
- admin.integration.spec.ts # 集成测试
|
||||
- location.property.spec.ts # 属性测试(管理员模块)
|
||||
- auth.e2e.spec.ts # E2E测试
|
||||
- websocket.perf.spec.ts # 性能测试
|
||||
```
|
||||
|
||||
## 🏗️ 文件夹结构检查
|
||||
|
||||
### 检查方法(必须使用工具)
|
||||
1. **使用listDirectory工具**:`listDirectory(path, depth=2)`获取完整结构
|
||||
2. **统计文件数量**:逐个文件夹统计文件数量
|
||||
3. **识别单文件文件夹**:只有1个文件的文件夹
|
||||
4. **执行扁平化**:将文件移动到上级目录
|
||||
5. **更新引用路径**:修改所有import语句
|
||||
|
||||
### 扁平化标准
|
||||
- **1个文件**:必须扁平化处理
|
||||
- **2个文件**:建议扁平化处理(除非是完整功能模块)
|
||||
- **≥3个文件**:保持独立文件夹
|
||||
- **完整功能模块**:即使文件较少也可保持独立(需特殊说明)
|
||||
|
||||
### 测试文件位置规范(重要)
|
||||
- ✅ **正确**:测试文件与源文件放在同一目录
|
||||
- ❌ **错误**:测试文件放在单独的tests/、test/、spec/、__tests__/文件夹
|
||||
|
||||
```
|
||||
✅ 正确结构:
|
||||
src/business/auth/
|
||||
├── auth.service.ts
|
||||
├── auth.service.spec.ts
|
||||
├── auth.controller.ts
|
||||
└── auth.controller.spec.ts
|
||||
|
||||
❌ 错误结构:
|
||||
src/business/auth/
|
||||
├── auth.service.ts
|
||||
├── auth.controller.ts
|
||||
└── tests/
|
||||
├── auth.service.spec.ts
|
||||
└── auth.controller.spec.ts
|
||||
```
|
||||
|
||||
## 🔧 Core层命名规则
|
||||
|
||||
### 业务支撑模块(使用_core后缀)
|
||||
专门为特定业务功能提供技术支撑:
|
||||
```
|
||||
✅ 正确:
|
||||
- location_broadcast_core/ # 为位置广播业务提供技术支撑
|
||||
- admin_core/ # 为管理员业务提供技术支撑
|
||||
- user_auth_core/ # 为用户认证业务提供技术支撑
|
||||
```
|
||||
|
||||
### 通用工具模块(不使用后缀)
|
||||
提供可复用的数据访问或技术服务:
|
||||
```
|
||||
✅ 正确:
|
||||
- user_profiles/ # 通用用户档案数据访问
|
||||
- redis/ # 通用Redis技术封装
|
||||
- logger/ # 通用日志工具服务
|
||||
```
|
||||
|
||||
### 判断方法
|
||||
```
|
||||
1. 模块是否专门为某个特定业务服务?
|
||||
├─ 是 → 使用_core后缀
|
||||
└─ 否 → 不使用后缀
|
||||
|
||||
2. 实际案例:
|
||||
- user_profiles: 通用数据访问 → 不使用后缀 ✓
|
||||
- location_broadcast_core: 专门为位置广播服务 → 使用_core后缀 ✓
|
||||
```
|
||||
|
||||
## ⚠️ 常见检查错误
|
||||
|
||||
1. **只看文件夹名称,不检查内容**
|
||||
2. **凭印象判断,不使用工具获取准确数据**
|
||||
3. **遗漏单文件或双文件文件夹的识别**
|
||||
4. **忽略测试文件夹扁平化**:认为tests文件夹是"标准结构"
|
||||
5. **🚨 错误地要求修改 NestJS 框架文件命名**:
|
||||
- ❌ 错误:要求将 `login.controller.ts` 改为 `login_controller.ts`(类型标识符不能用下划线)
|
||||
- ❌ 错误:要求将 `userProfile.service.ts` 改为 `userProfile.service.ts`(文件名应该用 snake_case)
|
||||
- ✅ 正确:`user_profile.service.ts`(文件名用 snake_case + 类型标识符用点分隔)
|
||||
- **判断方法**:
|
||||
- 检查类型标识符是否用点分隔(`.controller`、`.service` 等)
|
||||
- 检查文件名本身是否用 snake_case
|
||||
- 完整格式:`snake_case文件名.类型标识符.ts`
|
||||
|
||||
## 🔍 检查执行步骤
|
||||
|
||||
1. **使用listDirectory工具检查目标文件夹结构**
|
||||
2. **逐个检查文件和文件夹命名是否符合规范**
|
||||
3. **统计每个文件夹的文件数量**
|
||||
4. **识别需要扁平化的文件夹(1-2个文件)**
|
||||
5. **检查Core层模块命名是否正确**
|
||||
6. **执行必要的文件移动和重命名操作**
|
||||
7. **更新所有相关的import路径引用**
|
||||
8. **验证修改后的结构和命名**
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(文件重命名、移动、删除等),必须立即重新执行步骤1的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤1 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接进入步骤2(错误做法)
|
||||
|
||||
**🚨 重要强调:纯检查步骤不更新修改记录**
|
||||
**如果检查发现命名已经符合规范,无需任何修改,则:**
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤1:命名规范检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||
|
||||
**不能跳过重新检查环节!**
|
||||
@@ -1,290 +0,0 @@
|
||||
# 步骤2:注释规范检查
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
检查和完善所有注释规范,确保文件头、类、方法注释的完整性和准确性。
|
||||
|
||||
## 📋 注释规范标准
|
||||
|
||||
### 文件头注释(必须包含)
|
||||
```typescript
|
||||
/**
|
||||
* 文件功能描述
|
||||
*
|
||||
* 功能描述:
|
||||
* - 主要功能点1
|
||||
* - 主要功能点2
|
||||
* - 主要功能点3
|
||||
*
|
||||
* 职责分离:
|
||||
* - 职责描述1
|
||||
* - 职责描述2
|
||||
*
|
||||
* 最近修改:
|
||||
* - [用户日期]: 修改类型 - 修改内容 (修改者: [用户名称])
|
||||
* - [历史日期]: 修改类型 - 修改内容 (修改者: 历史修改者)
|
||||
*
|
||||
* @author [处理后的作者名称]
|
||||
* @version x.x.x
|
||||
* @since [创建日期]
|
||||
* @lastModified [用户日期]
|
||||
*/
|
||||
```
|
||||
|
||||
### 类注释(必须包含)
|
||||
```typescript
|
||||
/**
|
||||
* 类功能描述
|
||||
*
|
||||
* 职责:
|
||||
* - 主要职责1
|
||||
* - 主要职责2
|
||||
*
|
||||
* 主要方法:
|
||||
* - method1() - 方法1功能
|
||||
* - method2() - 方法2功能
|
||||
*
|
||||
* 使用场景:
|
||||
* - 场景描述
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExampleService {
|
||||
// 类实现
|
||||
}
|
||||
```
|
||||
|
||||
### 方法注释(必须包含)
|
||||
```typescript
|
||||
/**
|
||||
* 方法功能描述
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 步骤1描述
|
||||
* 2. 步骤2描述
|
||||
* 3. 步骤3描述
|
||||
*
|
||||
* @param paramName 参数描述
|
||||
* @returns 返回值描述
|
||||
* @throws ExceptionType 异常情况描述
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await service.methodName(param);
|
||||
* ```
|
||||
*/
|
||||
async methodName(paramName: ParamType): Promise<ReturnType> {
|
||||
// 方法实现
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 @author字段处理规范
|
||||
|
||||
### 处理原则
|
||||
- **保留人名**:如果@author是人名,必须保留不变
|
||||
- **替换AI标识**:只有AI标识才可替换为用户名称
|
||||
|
||||
### 判断标准
|
||||
```typescript
|
||||
// ✅ 可以替换的AI标识
|
||||
@author kiro → 替换为 @author [用户名称]
|
||||
@author ChatGPT → 替换为 @author [用户名称]
|
||||
@author Claude → 替换为 @author [用户名称]
|
||||
@author AI → 替换为 @author [用户名称]
|
||||
|
||||
// ❌ 必须保留的人名
|
||||
@author 张三 → 保留为 @author 张三
|
||||
@author John Smith → 保留为 @author John Smith
|
||||
@author 李四 → 保留为 @author 李四
|
||||
```
|
||||
|
||||
## 📝 修改记录规范
|
||||
|
||||
### 检查要点
|
||||
步骤2需要检查文件头注释中的修改记录是否符合全局规范(详见README.md全局约束部分):
|
||||
|
||||
- ✅ 修改记录格式是否正确
|
||||
- ✅ 修改类型是否准确
|
||||
- ✅ 用户日期和名称是否正确使用
|
||||
- ✅ 版本号是否按规则递增
|
||||
- ✅ @lastModified字段是否正确更新
|
||||
|
||||
### 常见检查项
|
||||
```typescript
|
||||
// ✅ 检查修改记录格式
|
||||
/**
|
||||
* 最近修改:
|
||||
* - [用户日期]: 代码规范优化 - 清理未使用的导入 (修改者: [用户名称])
|
||||
* - 历史记录...
|
||||
*/
|
||||
|
||||
// ✅ 检查版本号递增
|
||||
@version 1.0.1 // 代码规范优化应该递增修订版本
|
||||
|
||||
// ✅ 检查时间戳更新
|
||||
@lastModified [用户日期] // 只有实际修改才更新
|
||||
```
|
||||
|
||||
**注意:具体的修改记录规范请参考README.md中的全局约束部分**
|
||||
|
||||
## 📊 版本号递增规则
|
||||
|
||||
### 检查要点
|
||||
步骤2需要检查版本号是否按照全局规范正确递增(详见README.md全局约束部分):
|
||||
|
||||
- ✅ 代码规范优化、Bug修复 → 修订版本+1
|
||||
- ✅ 功能新增、功能修改 → 次版本+1
|
||||
- ✅ 重构、架构变更 → 主版本+1
|
||||
|
||||
### 检查示例
|
||||
```typescript
|
||||
// 检查版本号递增是否正确
|
||||
@version 1.0.0 → @version 1.0.1 // 代码规范优化
|
||||
@version 1.0.1 → @version 1.1.0 // 功能新增
|
||||
@version 1.1.0 → @version 2.0.0 // 重构
|
||||
```
|
||||
|
||||
## ⏰ 时间更新规则
|
||||
|
||||
### 检查要点
|
||||
步骤2需要检查时间戳更新是否符合全局规范(详见README.md全局约束部分):
|
||||
|
||||
- ✅ 仅检查不修改时,不更新@lastModified字段
|
||||
- ✅ 实际修改文件内容时,才更新@lastModified字段
|
||||
- ✅ 使用Git变更检测确认文件是否真正被修改
|
||||
|
||||
### 🚨 重要强调:纯检查不更新修改记录
|
||||
**步骤2注释规范检查时,如果发现注释已经符合规范,无需任何修改,则:**
|
||||
|
||||
#### 禁止的操作
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤2:注释规范检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ❌ **禁止修改任何现有内容**:包括修改记录、作者信息等
|
||||
|
||||
#### 正确的做法
|
||||
- ✅ **仅进行检查**:验证注释规范是否符合要求
|
||||
- ✅ **提供检查报告**:说明检查结果和符合情况
|
||||
- ✅ **保持文件不变**:如果符合规范就不修改任何内容
|
||||
|
||||
### 实际修改才更新的情况
|
||||
**只有在以下情况下才需要更新修改记录:**
|
||||
- 添加了缺失的文件头注释
|
||||
- 补充了不完整的类注释
|
||||
- 完善了缺失的方法注释
|
||||
- 修正了错误的@author字段(AI标识替换为用户名)
|
||||
- 修复了格式错误的注释结构
|
||||
|
||||
### Git变更检测检查
|
||||
```bash
|
||||
git status # 检查是否有文件被修改
|
||||
git diff [filename] # 检查具体修改内容
|
||||
```
|
||||
|
||||
**只有git显示文件被修改时,才需要添加修改记录和更新时间戳**
|
||||
|
||||
**注意:具体的时间更新规则请参考README.md中的全局约束部分**
|
||||
|
||||
## 🎮 游戏服务器特殊注释要求
|
||||
|
||||
### WebSocket Gateway注释
|
||||
```typescript
|
||||
/**
|
||||
* 位置广播WebSocket网关
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理客户端WebSocket连接
|
||||
* - 实时广播用户位置更新
|
||||
* - 管理游戏房间成员
|
||||
*
|
||||
* WebSocket事件:
|
||||
* - connection: 客户端连接事件
|
||||
* - position_update: 位置更新事件
|
||||
* - disconnect: 客户端断开事件
|
||||
*/
|
||||
```
|
||||
|
||||
### 双模式服务注释
|
||||
```typescript
|
||||
/**
|
||||
* 用户服务(内存模式)
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供用户数据的内存存储访问
|
||||
* - 支持开发测试和故障降级场景
|
||||
* - 与数据库模式保持接口一致性
|
||||
*
|
||||
* 模式特点:
|
||||
* - 数据存储在内存Map中
|
||||
* - 应用重启后数据丢失
|
||||
* - 适用于开发测试环境
|
||||
*/
|
||||
```
|
||||
|
||||
### 属性测试注释
|
||||
```typescript
|
||||
/**
|
||||
* 管理员服务属性测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 使用fast-check进行基于属性的随机测试
|
||||
* - 验证管理员操作的正确性和边界条件
|
||||
* - 自动发现潜在的边界情况问题
|
||||
*
|
||||
* 测试策略:
|
||||
* - 随机生成用户状态变更
|
||||
* - 验证操作结果的一致性
|
||||
* - 检查异常处理的完整性
|
||||
*/
|
||||
```
|
||||
|
||||
## 🔍 检查执行步骤
|
||||
|
||||
1. **检查文件头注释完整性**
|
||||
- 功能描述是否清晰
|
||||
- 职责分离是否明确
|
||||
- 修改记录是否使用用户信息
|
||||
- @author字段是否正确处理
|
||||
|
||||
2. **检查类注释完整性**
|
||||
- 职责描述是否清晰
|
||||
- 主要方法是否列出
|
||||
- 使用场景是否说明
|
||||
|
||||
3. **检查方法注释完整性**
|
||||
- 业务逻辑步骤是否详细
|
||||
- @param、@returns、@throws是否完整
|
||||
- @example是否提供
|
||||
|
||||
4. **验证修改记录和版本号**
|
||||
- 使用git检查文件是否有实际变更
|
||||
- 根据修改类型正确递增版本号
|
||||
- 只有实际修改才更新时间戳
|
||||
|
||||
5. **特殊文件类型注释检查**
|
||||
- WebSocket Gateway的事件说明
|
||||
- 双模式服务的模式特点
|
||||
- 属性测试的测试策略
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(添加注释、更新修改记录、修正@author字段等),必须立即重新执行步骤2的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤2 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接进入步骤3(错误做法)
|
||||
|
||||
**不能跳过重新检查环节!**
|
||||
@@ -1,578 +0,0 @@
|
||||
# 步骤3:代码质量检查
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
清理和优化代码质量,消除未使用代码、规范常量定义、处理TODO项。
|
||||
|
||||
## 🧹 未使用代码清理
|
||||
|
||||
### 清理未使用的导入
|
||||
```typescript
|
||||
// ❌ 错误:导入未使用的模块
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { User, Admin } from './user.entity';
|
||||
import * as crypto from 'crypto'; // 未使用
|
||||
import { RedisService } from '../redis/redis.service'; // 未使用
|
||||
|
||||
// ✅ 正确:只导入使用的模块
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { User } from './user.entity';
|
||||
```
|
||||
|
||||
### 清理未使用的变量
|
||||
```typescript
|
||||
// ❌ 错误:定义但未使用的变量
|
||||
const unusedVariable = 'test';
|
||||
let tempData = [];
|
||||
|
||||
// ✅ 正确:删除未使用的变量
|
||||
// 只保留实际使用的变量
|
||||
```
|
||||
|
||||
### 清理未使用的方法
|
||||
```typescript
|
||||
// ❌ 错误:定义但未调用的私有方法
|
||||
private generateVerificationCode(): string {
|
||||
// 如果这个方法没有被调用,应该删除
|
||||
}
|
||||
|
||||
// ✅ 正确:删除未使用的私有方法
|
||||
// 或者确保方法被正确调用
|
||||
```
|
||||
|
||||
## 📊 常量定义规范
|
||||
|
||||
### 使用SCREAMING_SNAKE_CASE
|
||||
```typescript
|
||||
// ✅ 正确:使用全大写+下划线
|
||||
const SALT_ROUNDS = 10;
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const WEBSOCKET_TIMEOUT = 30000;
|
||||
const MAX_ROOM_CAPACITY = 100;
|
||||
|
||||
// ❌ 错误:使用小驼峰
|
||||
const saltRounds = 10;
|
||||
const maxLoginAttempts = 5;
|
||||
const defaultPageSize = 20;
|
||||
```
|
||||
|
||||
### 提取魔法数字为常量
|
||||
```typescript
|
||||
// ❌ 错误:使用魔法数字
|
||||
if (attempts > 5) {
|
||||
throw new Error('Too many attempts');
|
||||
}
|
||||
setTimeout(callback, 30000);
|
||||
|
||||
// ✅ 正确:提取为常量
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const WEBSOCKET_TIMEOUT = 30000;
|
||||
|
||||
if (attempts > MAX_LOGIN_ATTEMPTS) {
|
||||
throw new Error('Too many attempts');
|
||||
}
|
||||
setTimeout(callback, WEBSOCKET_TIMEOUT);
|
||||
```
|
||||
|
||||
## 📏 方法长度检查
|
||||
|
||||
### 长度限制
|
||||
- **建议**:方法不超过50行
|
||||
- **原则**:一个方法只做一件事
|
||||
- **拆分**:复杂方法拆分为多个小方法
|
||||
|
||||
### 方法拆分示例
|
||||
```typescript
|
||||
// ❌ 错误:方法过长(超过50行)
|
||||
async processUserRegistration(userData: CreateUserDto): Promise<User> {
|
||||
// 验证用户数据
|
||||
// 检查邮箱是否存在
|
||||
// 生成密码哈希
|
||||
// 创建用户记录
|
||||
// 发送欢迎邮件
|
||||
// 记录操作日志
|
||||
// 返回用户信息
|
||||
// ... 超过50行的复杂逻辑
|
||||
}
|
||||
|
||||
// ✅ 正确:拆分为多个小方法
|
||||
async processUserRegistration(userData: CreateUserDto): Promise<User> {
|
||||
await this.validateUserData(userData);
|
||||
await this.checkEmailExists(userData.email);
|
||||
const hashedPassword = await this.generatePasswordHash(userData.password);
|
||||
const user = await this.createUserRecord({ ...userData, password: hashedPassword });
|
||||
await this.sendWelcomeEmail(user.email);
|
||||
await this.logUserRegistration(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async validateUserData(userData: CreateUserDto): Promise<void> {
|
||||
// 验证逻辑
|
||||
}
|
||||
|
||||
private async checkEmailExists(email: string): Promise<void> {
|
||||
// 邮箱检查逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 代码重复消除
|
||||
|
||||
### 识别重复代码
|
||||
```typescript
|
||||
// ❌ 错误:重复的验证逻辑
|
||||
async createUser(userData: CreateUserDto): Promise<User> {
|
||||
if (!userData.email || !userData.name) {
|
||||
throw new BadRequestException('Required fields missing');
|
||||
}
|
||||
if (!this.isValidEmail(userData.email)) {
|
||||
throw new BadRequestException('Invalid email format');
|
||||
}
|
||||
// 创建用户逻辑
|
||||
}
|
||||
|
||||
async updateUser(id: string, userData: UpdateUserDto): Promise<User> {
|
||||
if (!userData.email || !userData.name) {
|
||||
throw new BadRequestException('Required fields missing');
|
||||
}
|
||||
if (!this.isValidEmail(userData.email)) {
|
||||
throw new BadRequestException('Invalid email format');
|
||||
}
|
||||
// 更新用户逻辑
|
||||
}
|
||||
|
||||
// ✅ 正确:抽象为可复用方法
|
||||
async createUser(userData: CreateUserDto): Promise<User> {
|
||||
this.validateUserData(userData);
|
||||
// 创建用户逻辑
|
||||
}
|
||||
|
||||
async updateUser(id: string, userData: UpdateUserDto): Promise<User> {
|
||||
this.validateUserData(userData);
|
||||
// 更新用户逻辑
|
||||
}
|
||||
|
||||
private validateUserData(userData: CreateUserDto | UpdateUserDto): void {
|
||||
if (!userData.email || !userData.name) {
|
||||
throw new BadRequestException('Required fields missing');
|
||||
}
|
||||
if (!this.isValidEmail(userData.email)) {
|
||||
throw new BadRequestException('Invalid email format');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 异常处理完整性检查(关键规范)
|
||||
|
||||
### 问题定义
|
||||
**异常吞没(Exception Swallowing)** 是指在 catch 块中捕获异常后,只记录日志但不重新抛出,导致:
|
||||
- 调用方无法感知错误
|
||||
- 方法返回 undefined 而非声明的类型
|
||||
- 数据不一致或静默失败
|
||||
- 难以调试和定位问题
|
||||
|
||||
### 检查规则
|
||||
|
||||
#### 规则1:catch 块必须有明确的异常处理策略
|
||||
```typescript
|
||||
// ❌ 严重错误:catch 块吞没异常
|
||||
async create(createDto: CreateDto): Promise<ResponseDto> {
|
||||
try {
|
||||
const result = await this.repository.create(createDto);
|
||||
return this.toResponseDto(result);
|
||||
} catch (error) {
|
||||
this.logger.error('创建失败', error);
|
||||
// 错误:没有 throw,方法返回 undefined
|
||||
// 但声明返回 Promise<ResponseDto>
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:只记录日志不处理
|
||||
async findById(id: string): Promise<Entity> {
|
||||
try {
|
||||
return await this.repository.findById(id);
|
||||
} catch (error) {
|
||||
monitor.error(error);
|
||||
// 错误:异常被吞没,调用方无法感知
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:重新抛出异常
|
||||
async create(createDto: CreateDto): Promise<ResponseDto> {
|
||||
try {
|
||||
const result = await this.repository.create(createDto);
|
||||
return this.toResponseDto(result);
|
||||
} catch (error) {
|
||||
this.logger.error('创建失败', error);
|
||||
throw error; // 必须重新抛出
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:转换为特定异常类型
|
||||
async create(createDto: CreateDto): Promise<ResponseDto> {
|
||||
try {
|
||||
const result = await this.repository.create(createDto);
|
||||
return this.toResponseDto(result);
|
||||
} catch (error) {
|
||||
this.logger.error('创建失败', error);
|
||||
if (error.message.includes('duplicate')) {
|
||||
throw new ConflictException('记录已存在');
|
||||
}
|
||||
throw error; // 其他错误继续抛出
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:返回错误响应(仅限顶层API)
|
||||
async create(createDto: CreateDto): Promise<ApiResponse<ResponseDto>> {
|
||||
try {
|
||||
const result = await this.repository.create(createDto);
|
||||
return { success: true, data: this.toResponseDto(result) };
|
||||
} catch (error) {
|
||||
this.logger.error('创建失败', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
errorCode: 'CREATE_FAILED'
|
||||
}; // 顶层API可以返回错误响应
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 规则2:Service 层方法必须传播异常
|
||||
```typescript
|
||||
// ❌ 错误:Service 层吞没异常
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
async update(id: string, dto: UpdateDto): Promise<ResponseDto> {
|
||||
try {
|
||||
const result = await this.repository.update(id, dto);
|
||||
return this.toResponseDto(result);
|
||||
} catch (error) {
|
||||
this.logger.error('更新失败', { id, error });
|
||||
// 错误:Service 层不应吞没异常
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:Service 层传播异常
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
async update(id: string, dto: UpdateDto): Promise<ResponseDto> {
|
||||
try {
|
||||
const result = await this.repository.update(id, dto);
|
||||
return this.toResponseDto(result);
|
||||
} catch (error) {
|
||||
this.logger.error('更新失败', { id, error });
|
||||
throw error; // 传播给调用方处理
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 规则3:Repository 层必须传播数据库异常
|
||||
```typescript
|
||||
// ❌ 错误:Repository 层吞没数据库异常
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
async findById(id: bigint): Promise<User | null> {
|
||||
try {
|
||||
return await this.repository.findOne({ where: { id } });
|
||||
} catch (error) {
|
||||
this.logger.error('查询失败', { id, error });
|
||||
// 错误:数据库异常被吞没,调用方以为查询成功但返回 null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:Repository 层传播异常
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
async findById(id: bigint): Promise<User | null> {
|
||||
try {
|
||||
return await this.repository.findOne({ where: { id } });
|
||||
} catch (error) {
|
||||
this.logger.error('查询失败', { id, error });
|
||||
throw error; // 数据库异常必须传播
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 异常处理层级规范
|
||||
|
||||
| 层级 | 异常处理策略 | 说明 |
|
||||
|------|-------------|------|
|
||||
| **Repository 层** | 必须 throw | 数据访问异常必须传播 |
|
||||
| **Service 层** | 必须 throw | 业务异常必须传播给调用方 |
|
||||
| **Business 层** | 必须 throw | 业务逻辑异常必须传播 |
|
||||
| **Gateway/Controller 层** | 可以转换为 HTTP 响应 | 顶层可以将异常转换为错误响应 |
|
||||
|
||||
### 检查清单
|
||||
|
||||
- [ ] **所有 catch 块是否有 throw 语句?**
|
||||
- [ ] **方法返回类型与实际返回是否一致?**(避免返回 undefined)
|
||||
- [ ] **Service/Repository 层是否传播异常?**
|
||||
- [ ] **只有顶层 API 才能将异常转换为错误响应?**
|
||||
- [ ] **异常日志是否包含足够的上下文信息?**
|
||||
|
||||
### 快速检查命令
|
||||
```bash
|
||||
# 搜索可能吞没异常的 catch 块(没有 throw 的 catch)
|
||||
# 在代码审查时重点关注这些位置
|
||||
grep -rn "catch.*error" --include="*.ts" | grep -v "throw"
|
||||
```
|
||||
|
||||
### 常见错误模式
|
||||
|
||||
#### 模式1:性能监控后忘记抛出
|
||||
```typescript
|
||||
// ❌ 常见错误
|
||||
} catch (error) {
|
||||
monitor.error(error); // 只记录监控
|
||||
// 忘记 throw error;
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
} catch (error) {
|
||||
monitor.error(error);
|
||||
throw error; // 必须抛出
|
||||
}
|
||||
```
|
||||
|
||||
#### 模式2:条件分支遗漏 throw
|
||||
```typescript
|
||||
// ❌ 常见错误
|
||||
} catch (error) {
|
||||
if (error.code === 'DUPLICATE') {
|
||||
throw new ConflictException('已存在');
|
||||
}
|
||||
// else 分支忘记 throw
|
||||
this.logger.error(error);
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
} catch (error) {
|
||||
if (error.code === 'DUPLICATE') {
|
||||
throw new ConflictException('已存在');
|
||||
}
|
||||
this.logger.error(error);
|
||||
throw error; // else 分支也要抛出
|
||||
}
|
||||
```
|
||||
|
||||
#### 模式3:返回类型不匹配
|
||||
```typescript
|
||||
// ❌ 错误:声明返回 Promise<Entity> 但可能返回 undefined
|
||||
async findById(id: string): Promise<Entity> {
|
||||
try {
|
||||
return await this.repo.findById(id);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
// 没有 throw,TypeScript 不会报错但运行时返回 undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
async findById(id: string): Promise<Entity> {
|
||||
try {
|
||||
return await this.repo.findById(id);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚫 TODO项处理(强制要求)
|
||||
|
||||
### 处理原则
|
||||
**最终文件不能包含TODO项**,必须:
|
||||
1. **真正实现功能**
|
||||
2. **删除未完成代码**
|
||||
|
||||
### 常见TODO处理
|
||||
```typescript
|
||||
// ❌ 错误:包含TODO项的代码
|
||||
async getUserProfile(id: string): Promise<UserProfile> {
|
||||
// TODO: 实现用户档案查询
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async sendSmsVerification(phone: string): Promise<void> {
|
||||
// TODO: 集成短信服务提供商
|
||||
throw new Error('SMS service not implemented');
|
||||
}
|
||||
|
||||
// ✅ 正确:真正实现功能
|
||||
async getUserProfile(id: string): Promise<UserProfile> {
|
||||
const profile = await this.userProfileRepository.findOne({
|
||||
where: { userId: id }
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new NotFoundException('用户档案不存在');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ✅ 正确:如果功能不需要,删除方法
|
||||
// 删除sendSmsVerification方法及其调用
|
||||
```
|
||||
|
||||
## 🎮 游戏服务器特殊质量要求
|
||||
|
||||
### WebSocket连接管理
|
||||
```typescript
|
||||
// ✅ 正确:完整的连接管理
|
||||
const MAX_CONNECTIONS_PER_ROOM = 100;
|
||||
const CONNECTION_TIMEOUT = 30000;
|
||||
const HEARTBEAT_INTERVAL = 10000;
|
||||
|
||||
@WebSocketGateway()
|
||||
export class LocationBroadcastGateway {
|
||||
private readonly connections = new Map<string, Socket>();
|
||||
|
||||
handleConnection(client: Socket): void {
|
||||
this.validateConnection(client);
|
||||
this.setupHeartbeat(client);
|
||||
this.trackConnection(client);
|
||||
}
|
||||
|
||||
private validateConnection(client: Socket): void {
|
||||
// 连接验证逻辑
|
||||
}
|
||||
|
||||
private setupHeartbeat(client: Socket): void {
|
||||
// 心跳检测逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 双模式服务质量
|
||||
```typescript
|
||||
// ✅ 正确:确保两种模式行为一致
|
||||
const DEFAULT_USER_STATUS = UserStatus.PENDING;
|
||||
const MAX_BATCH_SIZE = 1000;
|
||||
|
||||
@Injectable()
|
||||
export class UsersMemoryService {
|
||||
private readonly users = new Map<string, User>();
|
||||
|
||||
async create(userData: CreateUserDto): Promise<User> {
|
||||
this.validateUserData(userData);
|
||||
const user = this.buildUserEntity(userData);
|
||||
this.users.set(user.id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private validateUserData(userData: CreateUserDto): void {
|
||||
// 与数据库模式相同的验证逻辑
|
||||
}
|
||||
|
||||
private buildUserEntity(userData: CreateUserDto): User {
|
||||
// 与数据库模式相同的实体构建逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 属性测试质量
|
||||
```typescript
|
||||
// ✅ 正确:完整的属性测试实现
|
||||
import * as fc from 'fast-check';
|
||||
|
||||
const PROPERTY_TEST_RUNS = 1000;
|
||||
const MAX_USER_ID = 1000000;
|
||||
|
||||
describe('AdminService Properties', () => {
|
||||
it('should handle any valid user status update', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.integer({ min: 1, max: MAX_USER_ID }),
|
||||
fc.constantFrom(...Object.values(UserStatus)),
|
||||
async (userId, status) => {
|
||||
try {
|
||||
const result = await adminService.updateUserStatus(userId, status);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe(status);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(NotFoundException || BadRequestException);
|
||||
}
|
||||
}
|
||||
), { numRuns: PROPERTY_TEST_RUNS });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🔍 检查执行步骤
|
||||
|
||||
1. **扫描未使用的导入**
|
||||
- 检查每个import语句是否被使用
|
||||
- 删除未使用的导入
|
||||
|
||||
2. **扫描未使用的变量和方法**
|
||||
- 检查变量是否被引用
|
||||
- 检查私有方法是否被调用
|
||||
- 删除未使用的代码
|
||||
|
||||
3. **检查常量定义**
|
||||
- 识别魔法数字和字符串
|
||||
- 提取为SCREAMING_SNAKE_CASE常量
|
||||
- 确保常量命名清晰
|
||||
|
||||
4. **检查方法长度**
|
||||
- 统计每个方法的行数
|
||||
- 识别超过50行的方法
|
||||
- 建议拆分复杂方法
|
||||
|
||||
5. **识别重复代码**
|
||||
- 查找相似的代码块
|
||||
- 抽象为可复用的工具方法
|
||||
- 消除代码重复
|
||||
|
||||
6. **🚨 检查异常处理完整性(关键步骤)**
|
||||
- 扫描所有 catch 块
|
||||
- 检查是否有 throw 语句
|
||||
- 验证 Service/Repository 层是否传播异常
|
||||
- 确认方法返回类型与实际返回一致
|
||||
- 识别异常吞没模式并修复
|
||||
|
||||
7. **处理所有TODO项**
|
||||
- 搜索所有TODO注释
|
||||
- 要求真正实现功能或删除代码
|
||||
- 确保最终文件无TODO项
|
||||
|
||||
8. **游戏服务器特殊检查**
|
||||
- WebSocket连接管理完整性
|
||||
- 双模式服务行为一致性
|
||||
- 属性测试实现质量
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(删除未使用代码、提取常量、实现TODO项等),必须立即重新执行步骤3的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤3 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接进入步骤4(错误做法)
|
||||
|
||||
**🚨 重要强调:纯检查步骤不更新修改记录**
|
||||
**如果检查发现代码质量已经符合规范,无需任何修改,则:**
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤3:代码质量检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||
|
||||
**不能跳过重新检查环节!**
|
||||
@@ -1,860 +0,0 @@
|
||||
# 步骤4:架构分层检查
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
检查架构分层的合规性,确保Core层和Business层职责清晰、依赖关系正确。
|
||||
|
||||
## 🏗️ 架构层级识别
|
||||
|
||||
### 项目分层结构
|
||||
```
|
||||
src/
|
||||
├── gateway/ # Gateway层:网关层(HTTP协议处理)
|
||||
│ ├── auth/ # 认证网关
|
||||
│ ├── users/ # 用户网关
|
||||
│ └── admin/ # 管理网关
|
||||
├── business/ # Business层:业务逻辑层
|
||||
│ ├── auth/ # 认证业务
|
||||
│ ├── users/ # 用户业务
|
||||
│ └── admin/ # 管理业务
|
||||
├── core/ # Core层:技术实现层
|
||||
│ ├── db/ # 数据访问
|
||||
│ ├── redis/ # 缓存服务
|
||||
│ └── utils/ # 工具服务
|
||||
└── common/ # 公共层:通用组件
|
||||
```
|
||||
|
||||
### 4层架构说明
|
||||
|
||||
**Gateway Layer(网关层)**
|
||||
- 位置:`src/gateway/`
|
||||
- 职责:HTTP协议处理、数据验证、路由管理、认证守卫、错误转换
|
||||
- 依赖:Business层
|
||||
|
||||
**Business Layer(业务层)**
|
||||
- 位置:`src/business/`
|
||||
- 职责:业务逻辑实现、业务流程控制、服务协调、业务规则验证
|
||||
- 依赖:Core层
|
||||
|
||||
**Core Layer(核心层)**
|
||||
- 位置:`src/core/`
|
||||
- 职责:数据访问、基础设施、外部系统集成、技术实现细节
|
||||
- 依赖:无(或第三方库)
|
||||
|
||||
### 检查范围
|
||||
- **限制范围**:仅检查当前执行检查的文件夹
|
||||
- **不跨模块**:不考虑其他同层功能模块
|
||||
- **专注职责**:确保当前模块职责清晰
|
||||
- **按层检查**:根据文件夹所在层级应用对应的检查规则
|
||||
|
||||
## 🌐 Gateway层规范检查
|
||||
|
||||
### 职责定义
|
||||
**Gateway层专注HTTP协议处理,不包含业务逻辑**
|
||||
|
||||
### Gateway层协议处理示例
|
||||
```typescript
|
||||
// ✅ 正确:Gateway层只做协议转换
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
constructor(private readonly loginService: LoginService) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
|
||||
// 1. 接收HTTP请求,使用DTO验证
|
||||
// 2. 调用Business层服务
|
||||
const result = await this.loginService.login({
|
||||
identifier: loginDto.identifier,
|
||||
password: loginDto.password
|
||||
});
|
||||
|
||||
// 3. 将业务响应转换为HTTP响应
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
private handleResponse(result: any, res: Response): void {
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
const statusCode = this.getErrorStatusCode(result);
|
||||
res.status(statusCode).json(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:Gateway层包含业务逻辑
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto): Promise<any> {
|
||||
// 错误:在Controller中实现业务逻辑
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { username: loginDto.identifier }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(loginDto.password, user.password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('密码错误');
|
||||
}
|
||||
|
||||
// ... 更多业务逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gateway层依赖关系检查
|
||||
```typescript
|
||||
// ✅ 允许的导入
|
||||
import { Controller, Post, Body, Res } from '@nestjs/common'; # NestJS框架
|
||||
import { Response } from 'express'; # Express类型
|
||||
import { LoginService } from '../../business/auth/login.service'; # Business层服务
|
||||
import { LoginDto } from './dto/login.dto'; # 同层DTO
|
||||
import { JwtAuthGuard } from './jwt_auth.guard'; # 同层Guard
|
||||
|
||||
// ❌ 禁止的导入
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service'; # 跳过Business层直接调用Core层
|
||||
import { UsersRepository } from '../../core/db/users/users.repository'; # 直接访问数据层
|
||||
import { RedisService } from '../../core/redis/redis.service'; # 直接访问技术服务
|
||||
```
|
||||
|
||||
### Gateway层文件类型检查
|
||||
```typescript
|
||||
// ✅ Gateway层应该包含的文件类型
|
||||
- *.controller.ts # HTTP控制器
|
||||
- *.dto.ts # 数据传输对象
|
||||
- *.guard.ts # 认证/授权守卫
|
||||
- *.decorator.ts # 参数装饰器
|
||||
- *.interceptor.ts # 拦截器
|
||||
- *.filter.ts # 异常过滤器
|
||||
- *.gateway.module.ts # 网关模块
|
||||
|
||||
// ❌ Gateway层不应该包含的文件类型
|
||||
- *.service.ts # 业务服务(应在Business层)
|
||||
- *.repository.ts # 数据仓库(应在Core层)
|
||||
- *.entity.ts # 数据实体(应在Core层)
|
||||
```
|
||||
|
||||
### Gateway层职责检查清单
|
||||
- [ ] Controller方法是否只做协议转换?
|
||||
- [ ] 是否使用DTO进行数据验证?
|
||||
- [ ] 是否调用Business层服务而非Core层?
|
||||
- [ ] 是否有统一的错误处理机制?
|
||||
- [ ] 是否包含Swagger API文档?
|
||||
- [ ] 是否使用限流和超时保护?
|
||||
|
||||
## 🔧 Core层规范检查
|
||||
|
||||
### 职责定义
|
||||
**Core层专注技术实现,不包含业务逻辑**
|
||||
|
||||
### 命名规范检查
|
||||
|
||||
#### 业务支撑模块(使用_core后缀)
|
||||
专门为特定业务功能提供技术支撑:
|
||||
```typescript
|
||||
✅ 正确示例:
|
||||
src/core/location_broadcast_core/ # 为位置广播业务提供技术支撑
|
||||
src/core/admin_core/ # 为管理员业务提供技术支撑
|
||||
src/core/user_auth_core/ # 为用户认证业务提供技术支撑
|
||||
src/core/zulip_core/ # 为Zulip集成提供技术支撑
|
||||
|
||||
❌ 错误示例:
|
||||
src/core/location_broadcast/ # 应该是location_broadcast_core
|
||||
src/core/admin/ # 应该是admin_core
|
||||
```
|
||||
|
||||
#### 通用工具模块(不使用后缀)
|
||||
提供可复用的数据访问或技术服务:
|
||||
```typescript
|
||||
✅ 正确示例:
|
||||
src/core/db/user_profiles/ # 通用的用户档案数据访问
|
||||
src/core/redis/ # 通用的Redis技术封装
|
||||
src/core/utils/logger/ # 通用的日志工具服务
|
||||
src/core/db/zulip_accounts/ # 通用的Zulip账户数据访问
|
||||
|
||||
❌ 错误示例:
|
||||
src/core/db/user_profiles_core/ # 应该是user_profiles(通用工具)
|
||||
src/core/redis_core/ # 应该是redis(通用工具)
|
||||
```
|
||||
|
||||
### 命名判断流程
|
||||
```
|
||||
1. 模块是否专门为某个特定业务功能服务?
|
||||
├─ 是 → 检查模块名称是否体现业务领域
|
||||
│ ├─ 是 → 使用 _core 后缀
|
||||
│ └─ 否 → 重新设计模块职责
|
||||
└─ 否 → 模块是否提供通用的技术服务?
|
||||
├─ 是 → 不使用 _core 后缀
|
||||
└─ 否 → 重新评估模块定位
|
||||
|
||||
2. 实际案例判断:
|
||||
- user_profiles: 通用的用户档案数据访问 → 不使用后缀 ✓
|
||||
- location_broadcast_core: 专门为位置广播业务服务 → 使用_core后缀 ✓
|
||||
- redis: 通用的缓存技术服务 → 不使用后缀 ✓
|
||||
- zulip_core: 专门为Zulip集成业务服务 → 使用_core后缀 ✓
|
||||
```
|
||||
|
||||
### Core层技术实现示例
|
||||
```typescript
|
||||
// ✅ 正确:Core层专注技术实现
|
||||
@Injectable()
|
||||
export class LocationBroadcastCoreService {
|
||||
/**
|
||||
* 广播位置更新到指定房间
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 验证WebSocket连接状态
|
||||
* 2. 序列化位置数据
|
||||
* 3. 通过Socket.IO广播消息
|
||||
* 4. 记录广播性能指标
|
||||
* 5. 处理广播异常和重试
|
||||
*/
|
||||
async broadcastToRoom(roomId: string, data: PositionData): Promise<void> {
|
||||
const room = this.server.sockets.adapter.rooms.get(roomId);
|
||||
if (!room) {
|
||||
throw new NotFoundException(`Room ${roomId} not found`);
|
||||
}
|
||||
|
||||
this.server.to(roomId).emit('position-update', data);
|
||||
this.metricsService.recordBroadcast(roomId, data.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:Core层包含业务逻辑
|
||||
@Injectable()
|
||||
export class LocationBroadcastCoreService {
|
||||
async broadcastUserPosition(userId: string, position: Position): Promise<void> {
|
||||
// 错误:包含了用户权限检查的业务概念
|
||||
const user = await this.userService.findById(userId);
|
||||
if (user.status !== UserStatus.ACTIVE) {
|
||||
throw new ForbiddenException('用户状态不允许位置广播');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Core层依赖关系检查
|
||||
```typescript
|
||||
// ✅ 允许的导入
|
||||
import { Injectable } from '@nestjs/common'; # NestJS框架
|
||||
import { Server } from 'socket.io'; # 第三方技术库
|
||||
import { RedisService } from '../redis/redis.service'; # 其他Core层模块
|
||||
import * as crypto from 'crypto'; # Node.js内置模块
|
||||
|
||||
// ❌ 禁止的导入
|
||||
import { UserBusinessService } from '../../business/users/user.service'; # Business层模块
|
||||
import { AdminController } from '../../business/admin/admin.controller'; # Business层模块
|
||||
```
|
||||
|
||||
## 💼 Business层规范检查
|
||||
|
||||
### 职责定义
|
||||
**Business层专注业务逻辑实现,不关心底层技术细节**
|
||||
|
||||
### 业务逻辑完备性检查
|
||||
```typescript
|
||||
// ✅ 正确:完整的业务逻辑
|
||||
@Injectable()
|
||||
export class UserBusinessService {
|
||||
/**
|
||||
* 用户注册业务流程
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户信息完整性
|
||||
* 2. 检查用户名/邮箱是否已存在
|
||||
* 3. 验证邮箱格式和域名白名单
|
||||
* 4. 生成用户唯一标识
|
||||
* 5. 设置默认用户权限
|
||||
* 6. 发送欢迎邮件
|
||||
* 7. 记录注册日志
|
||||
* 8. 返回注册结果
|
||||
*/
|
||||
async registerUser(registerData: RegisterUserDto): Promise<UserResult> {
|
||||
await this.validateUserBusinessRules(registerData);
|
||||
const user = await this.userCoreService.create(registerData);
|
||||
await this.emailService.sendWelcomeEmail(user.email);
|
||||
await this.logService.recordUserRegistration(user.id);
|
||||
return this.buildUserResult(user);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:业务逻辑不完整
|
||||
@Injectable()
|
||||
export class UserBusinessService {
|
||||
async registerUser(registerData: RegisterUserDto): Promise<User> {
|
||||
// 只是简单调用数据库保存,缺少业务验证和流程
|
||||
return this.userRepository.save(registerData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Business层依赖关系检查
|
||||
```typescript
|
||||
// ✅ 允许的导入
|
||||
import { UserCoreService } from '../../core/user_auth_core/user_core.service'; # 对应Core层业务支撑
|
||||
import { CacheService } from '../../core/redis/cache.service'; # Core层通用工具
|
||||
import { EmailService } from '../../core/utils/email.service'; # Core层通用工具
|
||||
import { OtherBusinessService } from '../other/other.service'; # 其他Business层(谨慎)
|
||||
|
||||
// ❌ 禁止的导入
|
||||
import { createConnection } from 'typeorm'; # 直接技术实现
|
||||
import * as Redis from 'ioredis'; # 直接技术实现
|
||||
import { DatabaseConnection } from '../../core/db/connection'; # 底层技术细节
|
||||
```
|
||||
|
||||
## 🚨 常见架构违规
|
||||
|
||||
### Gateway层违规示例
|
||||
```typescript
|
||||
// ❌ 错误:Gateway层包含业务逻辑
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
@Post('register')
|
||||
async register(@Body() registerDto: RegisterDto): Promise<User> {
|
||||
// 违规:在Controller中实现业务验证
|
||||
if (registerDto.age < 18) {
|
||||
throw new BadRequestException('用户年龄必须大于18岁');
|
||||
}
|
||||
|
||||
// 违规:在Controller中协调多个服务
|
||||
const user = await this.userCoreService.create(registerDto);
|
||||
await this.emailService.sendWelcomeEmail(user.email);
|
||||
await this.zulipService.createAccount(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:Gateway层直接调用Core层
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService, // 违规:跳过Business层
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto): Promise<any> {
|
||||
// 违规:直接调用Core层服务
|
||||
return this.loginCoreService.login(loginDto);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Business层违规示例
|
||||
```typescript
|
||||
// ❌ 错误:Business层包含技术实现细节
|
||||
@Injectable()
|
||||
export class UserBusinessService {
|
||||
async createUser(userData: CreateUserDto): Promise<User> {
|
||||
// 违规:直接操作Redis连接
|
||||
const redis = new Redis({ host: 'localhost', port: 6379 });
|
||||
await redis.set(`user:${userData.id}`, JSON.stringify(userData));
|
||||
|
||||
// 违规:直接写SQL语句
|
||||
const sql = 'INSERT INTO users (name, email) VALUES (?, ?)';
|
||||
await this.database.query(sql, [userData.name, userData.email]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Core层违规示例
|
||||
```typescript
|
||||
// ❌ 错误:Core层包含业务逻辑
|
||||
@Injectable()
|
||||
export class DatabaseService {
|
||||
async saveUser(userData: CreateUserDto): Promise<User> {
|
||||
// 违规:包含用户注册的业务验证
|
||||
if (userData.age < 18) {
|
||||
throw new BadRequestException('用户年龄必须大于18岁');
|
||||
}
|
||||
|
||||
// 违规:包含业务规则
|
||||
if (userData.email.endsWith('@competitor.com')) {
|
||||
throw new ForbiddenException('不允许竞争对手注册');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎮 游戏服务器架构特殊检查
|
||||
|
||||
### WebSocket Gateway分层
|
||||
```typescript
|
||||
// ✅ 正确:Gateway在Business层,调用Core层服务
|
||||
@WebSocketGateway()
|
||||
export class LocationBroadcastGateway {
|
||||
constructor(
|
||||
private readonly locationBroadcastCore: LocationBroadcastCoreService,
|
||||
private readonly userProfiles: UserProfilesService,
|
||||
) {}
|
||||
|
||||
@SubscribeMessage('position_update')
|
||||
async handlePositionUpdate(client: Socket, data: PositionData): Promise<void> {
|
||||
// 业务逻辑:验证、权限检查
|
||||
await this.validateUserPermission(client.userId);
|
||||
|
||||
// 调用Core层技术实现
|
||||
await this.locationBroadcastCore.broadcastToRoom(client.roomId, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 双模式服务分层
|
||||
```typescript
|
||||
// ✅ 正确:Business层统一接口,Core层不同实现
|
||||
@Injectable()
|
||||
export class UsersBusinessService {
|
||||
constructor(
|
||||
@Inject('USERS_SERVICE')
|
||||
private readonly usersCore: UsersMemoryService | UsersDatabaseService,
|
||||
) {}
|
||||
|
||||
async createUser(userData: CreateUserDto): Promise<User> {
|
||||
// 业务逻辑:验证、权限、流程
|
||||
await this.validateUserBusinessRules(userData);
|
||||
|
||||
// 调用Core层(内存或数据库模式)
|
||||
const user = await this.usersCore.create(userData);
|
||||
|
||||
// 业务逻辑:后续处理
|
||||
await this.sendWelcomeNotification(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 NestJS依赖注入检查(重要)
|
||||
|
||||
### 依赖注入完整性检查
|
||||
**在NestJS中,如果一个类(如Guard、Service、Controller)需要注入其他服务,必须确保该服务在模块的imports中可访问。**
|
||||
|
||||
### 常见依赖注入问题
|
||||
```typescript
|
||||
// ❌ 错误:JwtAuthGuard需要LoginCoreService,但模块未导入LoginCoreModule
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule, // AuthModule虽然导入了LoginCoreModule,但没有重新导出
|
||||
],
|
||||
providers: [
|
||||
JwtAuthGuard, // 错误:无法注入LoginCoreService
|
||||
],
|
||||
})
|
||||
export class AuthGatewayModule {}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard {
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService, // 注入失败!
|
||||
) {}
|
||||
}
|
||||
|
||||
// ✅ 正确方案1:直接导入需要的Core模块
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
LoginCoreModule, // 直接导入,使LoginCoreService可用
|
||||
],
|
||||
providers: [
|
||||
JwtAuthGuard, // 现在可以成功注入LoginCoreService
|
||||
],
|
||||
})
|
||||
export class AuthGatewayModule {}
|
||||
|
||||
// ✅ 正确方案2:在中间模块重新导出
|
||||
@Module({
|
||||
imports: [LoginCoreModule],
|
||||
exports: [LoginCoreModule], // 重新导出,让导入AuthModule的模块也能访问
|
||||
})
|
||||
export class AuthModule {}
|
||||
```
|
||||
|
||||
### 依赖注入检查规则
|
||||
|
||||
#### 1. 检查Provider的构造函数依赖
|
||||
```typescript
|
||||
// 对于每个Provider(Service、Guard、Interceptor等)
|
||||
@Injectable()
|
||||
export class SomeGuard {
|
||||
constructor(
|
||||
private readonly serviceA: ServiceA, // 依赖1
|
||||
private readonly serviceB: ServiceB, // 依赖2
|
||||
) {}
|
||||
}
|
||||
|
||||
// 检查清单:
|
||||
// ✓ ServiceA是否在当前模块的imports中?
|
||||
// ✓ ServiceB是否在当前模块的imports中?
|
||||
// ✓ 如果不在,是否需要添加对应的Module到imports?
|
||||
```
|
||||
|
||||
#### 2. 检查Module的导出完整性
|
||||
```typescript
|
||||
// ❌ 错误:导入了模块但没有导出,导致上层模块无法访问
|
||||
@Module({
|
||||
imports: [LoginCoreModule],
|
||||
providers: [LoginService],
|
||||
exports: [LoginService], // 只导出了LoginService,没有导出LoginCoreModule
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
// 如果上层模块需要直接使用LoginCoreService:
|
||||
@Module({
|
||||
imports: [AuthModule], // 无法访问LoginCoreService
|
||||
providers: [JwtAuthGuard], // JwtAuthGuard需要LoginCoreService,会失败
|
||||
})
|
||||
export class AuthGatewayModule {}
|
||||
|
||||
// ✅ 正确:根据需要导出Module
|
||||
@Module({
|
||||
imports: [LoginCoreModule],
|
||||
providers: [LoginService],
|
||||
exports: [
|
||||
LoginService,
|
||||
LoginCoreModule, // 导出Module,让上层也能访问
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
```
|
||||
|
||||
#### 3. 检查跨层依赖的模块导入
|
||||
```typescript
|
||||
// Gateway层的Guard直接依赖Core层Service的情况
|
||||
@Injectable()
|
||||
export class JwtAuthGuard {
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService, // 直接依赖Core层
|
||||
) {}
|
||||
}
|
||||
|
||||
// 检查清单:
|
||||
// ✓ AuthGatewayModule是否导入了LoginCoreModule?
|
||||
// ✓ 如果通过AuthModule间接导入,AuthModule是否导出了LoginCoreModule?
|
||||
// ✓ 是否符合架构分层原则(Gateway可以直接依赖Core用于技术实现)?
|
||||
```
|
||||
|
||||
### 依赖注入检查步骤
|
||||
|
||||
1. **扫描所有Injectable类**
|
||||
- 找出所有使用@Injectable()装饰器的类
|
||||
- 包括Service、Guard、Interceptor、Pipe等
|
||||
|
||||
2. **分析构造函数依赖**
|
||||
- 检查每个类的constructor参数
|
||||
- 列出所有需要注入的服务
|
||||
|
||||
3. **检查Module的imports**
|
||||
- 确认每个依赖的服务是否在Module的imports中
|
||||
- 检查imports的Module是否导出了需要的服务
|
||||
|
||||
4. **验证依赖链完整性**
|
||||
- 如果A模块导入B模块,B模块导入C模块
|
||||
- 确认A模块是否能访问C模块的服务(取决于B是否导出C)
|
||||
|
||||
5. **检查常见错误模式**
|
||||
- Guard/Interceptor依赖Service但模块未导入
|
||||
- 中间模块导入但未导出,导致上层无法访问
|
||||
- 循环依赖问题
|
||||
|
||||
### 依赖注入错误识别
|
||||
|
||||
#### 典型错误信息
|
||||
```
|
||||
Nest can't resolve dependencies of the JwtAuthGuard (?).
|
||||
Please make sure that the argument LoginCoreService at index [0]
|
||||
is available in the AuthGatewayModule context.
|
||||
```
|
||||
|
||||
#### 错误分析流程
|
||||
```
|
||||
1. 识别问题类:JwtAuthGuard
|
||||
2. 识别缺失依赖:LoginCoreService(索引0)
|
||||
3. 识别所在模块:AuthGatewayModule
|
||||
4. 检查解决方案:
|
||||
├─ LoginCoreService在哪个Module中提供?
|
||||
│ └─ 答:LoginCoreModule
|
||||
├─ AuthGatewayModule是否导入了LoginCoreModule?
|
||||
│ └─ 否 → 需要添加到imports
|
||||
└─ 如果通过其他Module间接导入,该Module是否导出了LoginCoreModule?
|
||||
└─ 否 → 需要在中间Module的exports中添加
|
||||
```
|
||||
|
||||
### 依赖注入最佳实践
|
||||
|
||||
```typescript
|
||||
// ✅ 推荐:明确的依赖关系
|
||||
@Module({
|
||||
imports: [
|
||||
// 业务层模块
|
||||
AuthModule,
|
||||
// 直接需要的核心层模块(用于Guard等技术组件)
|
||||
LoginCoreModule,
|
||||
],
|
||||
controllers: [LoginController],
|
||||
providers: [JwtAuthGuard],
|
||||
exports: [JwtAuthGuard],
|
||||
})
|
||||
export class AuthGatewayModule {}
|
||||
|
||||
// ✅ 推荐:完整的导出链
|
||||
@Module({
|
||||
imports: [LoginCoreModule, UsersModule],
|
||||
providers: [LoginService],
|
||||
exports: [
|
||||
LoginService, // 导出自己的服务
|
||||
LoginCoreModule, // 导出依赖的模块(如果上层需要)
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
```
|
||||
|
||||
## 🔍 检查执行步骤
|
||||
|
||||
1. **识别当前模块的层级**
|
||||
- 确定是Gateway层、Business层还是Core层
|
||||
- 检查文件夹路径和命名
|
||||
- 根据层级应用对应的检查规则
|
||||
|
||||
2. **Gateway层检查(如果是Gateway层)**
|
||||
- 检查是否只包含协议处理代码
|
||||
- 检查是否使用DTO进行数据验证
|
||||
- 检查是否只调用Business层服务
|
||||
- 检查是否有统一的错误处理
|
||||
- 检查文件类型是否符合Gateway层规范
|
||||
|
||||
3. **Business层检查(如果是Business层)**
|
||||
- 检查是否只包含业务逻辑
|
||||
- 检查是否协调多个Core层服务
|
||||
- 检查是否返回统一的业务响应
|
||||
- 检查是否不包含HTTP协议处理
|
||||
|
||||
4. **Core层检查(如果是Core层)**
|
||||
- 检查Core层命名规范
|
||||
- 业务支撑模块是否使用_core后缀
|
||||
- 通用工具模块是否不使用后缀
|
||||
- 根据模块职责判断命名正确性
|
||||
- 检查是否只包含技术实现
|
||||
|
||||
5. **检查职责分离**
|
||||
- Gateway层是否只做协议转换
|
||||
- Business层是否只包含业务逻辑
|
||||
- Core层是否只包含技术实现
|
||||
- 是否有跨层职责混乱
|
||||
|
||||
6. **🔥 检查依赖注入完整性(关键步骤)**
|
||||
- 扫描所有Injectable类的构造函数依赖
|
||||
- 检查Module的imports是否包含所有依赖的Module
|
||||
- 验证中间Module是否正确导出了需要的服务
|
||||
- 确认依赖链的完整性和可访问性
|
||||
- 识别并修复常见的依赖注入错误
|
||||
|
||||
7. **检查依赖关系**
|
||||
- Gateway层是否只依赖Business层
|
||||
- Business层是否只依赖Core层
|
||||
- Core层是否不依赖业务层
|
||||
- 依赖注入是否正确使用
|
||||
|
||||
8. **检查架构违规**
|
||||
- 识别常见的分层违规模式
|
||||
- 检查技术实现和业务逻辑的边界
|
||||
- 检查协议处理和业务逻辑的边界
|
||||
- 确保架构清晰度
|
||||
|
||||
9. **游戏服务器特殊检查**
|
||||
- WebSocket Gateway的分层正确性
|
||||
- 双模式服务的架构设计
|
||||
- 实时通信组件的职责分离
|
||||
|
||||
10. **🚀 应用启动验证(强制步骤)**
|
||||
- 执行 `pnpm dev` 或 `npm run dev` 启动应用
|
||||
- 验证应用能够成功启动,无模块依赖错误
|
||||
- 检查控制台是否有依赖注入失败的错误信息
|
||||
- 如有启动错误,必须修复后重新验证
|
||||
|
||||
## 🚀 应用启动验证(强制要求)
|
||||
|
||||
### 为什么需要启动验证?
|
||||
**静态代码检查无法发现所有的模块依赖问题!** 以下问题只有在应用启动时才会暴露:
|
||||
|
||||
1. **Module exports 配置错误**:导出了不属于当前模块的服务
|
||||
2. **依赖注入链断裂**:中间模块未正确导出依赖
|
||||
3. **循环依赖问题**:模块间存在循环引用
|
||||
4. **Provider 注册遗漏**:服务未在正确的模块中注册
|
||||
5. **CacheModule/ConfigModule 等全局模块缺失**
|
||||
|
||||
### 常见启动错误示例
|
||||
|
||||
#### 错误1:导出不属于当前模块的服务
|
||||
```
|
||||
UnknownExportException [Error]: Nest cannot export a provider/module that
|
||||
is not a part of the currently processed module (ZulipModule).
|
||||
Please verify whether the exported DynamicConfigManagerService is available
|
||||
in this particular context.
|
||||
```
|
||||
|
||||
**原因**:ZulipModule 尝试导出 DynamicConfigManagerService,但该服务来自 ZulipCoreModule,不是 ZulipModule 自己的 provider。
|
||||
|
||||
**修复方案**:
|
||||
```typescript
|
||||
// ❌ 错误:直接导出其他模块的服务
|
||||
@Module({
|
||||
imports: [ZulipCoreModule],
|
||||
exports: [DynamicConfigManagerService], // 错误!
|
||||
})
|
||||
export class ZulipModule {}
|
||||
|
||||
// ✅ 正确:导出整个模块
|
||||
@Module({
|
||||
imports: [ZulipCoreModule],
|
||||
exports: [ZulipCoreModule], // 正确:导出模块而非服务
|
||||
})
|
||||
export class ZulipModule {}
|
||||
```
|
||||
|
||||
#### 错误2:依赖注入失败
|
||||
```
|
||||
Nest can't resolve dependencies of the JwtAuthGuard (?).
|
||||
Please make sure that the argument LoginCoreService at index [0]
|
||||
is available in the ZulipGatewayModule context.
|
||||
```
|
||||
|
||||
**原因**:JwtAuthGuard 需要 LoginCoreService,但 ZulipGatewayModule 没有导入 LoginCoreModule。
|
||||
|
||||
**修复方案**:
|
||||
```typescript
|
||||
// ❌ 错误:缺少必要的模块导入
|
||||
@Module({
|
||||
imports: [ZulipModule, AuthModule],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
export class ZulipGatewayModule {}
|
||||
|
||||
// ✅ 正确:添加缺失的模块导入
|
||||
@Module({
|
||||
imports: [
|
||||
ZulipModule,
|
||||
AuthModule,
|
||||
LoginCoreModule, // 添加:JwtAuthGuard 依赖 LoginCoreService
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
export class ZulipGatewayModule {}
|
||||
```
|
||||
|
||||
#### 错误3:CACHE_MANAGER 未注册
|
||||
```
|
||||
Nest can't resolve dependencies of the SomeService (?).
|
||||
Please make sure that the argument "CACHE_MANAGER" at index [2]
|
||||
is available in the SomeModule context.
|
||||
```
|
||||
|
||||
**原因**:服务使用了 @Inject(CACHE_MANAGER),但模块未导入 CacheModule。
|
||||
|
||||
**修复方案**:
|
||||
```typescript
|
||||
// ❌ 错误:缺少 CacheModule
|
||||
@Module({
|
||||
imports: [OtherModule],
|
||||
providers: [SomeService],
|
||||
})
|
||||
export class SomeModule {}
|
||||
|
||||
// ✅ 正确:添加 CacheModule
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule.register(), // 添加缓存模块
|
||||
OtherModule,
|
||||
],
|
||||
providers: [SomeService],
|
||||
})
|
||||
export class SomeModule {}
|
||||
```
|
||||
|
||||
### 启动验证执行流程
|
||||
|
||||
```bash
|
||||
# 1. 执行启动命令
|
||||
pnpm dev
|
||||
# 或
|
||||
npm run dev
|
||||
|
||||
# 2. 观察控制台输出,检查是否有以下错误类型:
|
||||
# - UnknownExportException
|
||||
# - Nest can't resolve dependencies
|
||||
# - Circular dependency detected
|
||||
# - Module not found
|
||||
|
||||
# 3. 如果启动成功,应该看到类似输出:
|
||||
# [Nest] LOG [NestFactory] Starting Nest application...
|
||||
# [Nest] LOG [RoutesResolver] AppController {/}: +Xms
|
||||
# [Nest] LOG [NestApplication] Nest application successfully started +Xms
|
||||
|
||||
# 4. 验证健康检查接口
|
||||
curl http://localhost:3000/health
|
||||
# 应返回:{"status":"ok",...}
|
||||
```
|
||||
|
||||
### 启动验证检查清单
|
||||
|
||||
- [ ] 执行 `pnpm dev` 或 `npm run dev`
|
||||
- [ ] 确认无 UnknownExportException 错误
|
||||
- [ ] 确认无依赖注入失败错误
|
||||
- [ ] 确认无循环依赖错误
|
||||
- [ ] 确认应用成功启动并监听端口
|
||||
- [ ] 验证健康检查接口返回正常
|
||||
- [ ] 如有错误,修复后重新启动验证
|
||||
|
||||
### 🚨 启动验证失败处理
|
||||
|
||||
**如果启动验证失败,必须:**
|
||||
1. **分析错误信息**:识别具体的模块和依赖问题
|
||||
2. **定位问题模块**:找到报错的 Module 文件
|
||||
3. **修复依赖配置**:
|
||||
- 添加缺失的 imports
|
||||
- 修正错误的 exports
|
||||
- 注册缺失的 providers
|
||||
4. **重新启动验证**:修复后必须再次执行启动验证
|
||||
5. **记录修改**:更新文件头部的修改记录
|
||||
|
||||
**🔥 重要:启动验证是步骤4的强制完成条件,不能跳过!**
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(调整分层结构、修正依赖关系、重构代码等),必须立即重新执行步骤4的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤4 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接进入步骤5(错误做法)
|
||||
|
||||
**🚨 重要强调:纯检查步骤不更新修改记录**
|
||||
**如果检查发现架构分层已经符合规范,无需任何修改,则:**
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤4:架构分层检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||
|
||||
**🚀 步骤4完成的强制条件:**
|
||||
1. **架构分层检查通过**:Gateway/Business/Core层职责清晰
|
||||
2. **依赖注入检查通过**:所有Module的imports/exports配置正确
|
||||
3. **🔥 应用启动验证通过**:执行 `pnpm dev` 应用能成功启动,无依赖错误
|
||||
|
||||
**不能跳过应用启动验证环节!如果启动失败,必须修复后重新执行整个步骤4!**
|
||||
@@ -1,706 +0,0 @@
|
||||
# 步骤5:测试覆盖检查
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
检查测试文件的完整性和覆盖率,确保严格的一对一测试映射和测试分离。
|
||||
|
||||
## 📋 测试文件存在性检查
|
||||
|
||||
### 需要测试文件的类型
|
||||
```typescript
|
||||
✅ 必须有测试文件:
|
||||
- *.service.ts # Service类 - 业务逻辑类
|
||||
- *.controller.ts # Controller类 - 控制器类
|
||||
- *.gateway.ts # Gateway类 - WebSocket网关类
|
||||
- *.guard.ts # Guard类 - 守卫类(游戏服务器安全重要)
|
||||
- *.interceptor.ts # Interceptor类 - 拦截器类(日志监控重要)
|
||||
- *.middleware.ts # Middleware类 - 中间件类(性能监控重要)
|
||||
|
||||
❌ 不需要测试文件:
|
||||
- *.dto.ts # DTO类 - 数据传输对象
|
||||
- *.interface.ts # Interface文件 - 接口定义
|
||||
- *.constants.ts # Constants文件 - 常量定义
|
||||
- *.config.ts # Config文件 - 配置文件
|
||||
- *.utils.ts # 简单Utils工具类(复杂工具类需要)
|
||||
```
|
||||
|
||||
### 测试文件命名规范
|
||||
```typescript
|
||||
✅ 正确的一对一映射:
|
||||
src/business/auth/auth.service.ts
|
||||
src/business/auth/auth.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/admin/admin.gateway.ts
|
||||
src/business/admin/admin.gateway.spec.ts
|
||||
|
||||
❌ 错误的命名:
|
||||
src/business/auth/auth_services.spec.ts # 测试多个service,违反一对一原则
|
||||
src/business/auth/auth_test.spec.ts # 命名不对应
|
||||
```
|
||||
|
||||
## 🔥 严格一对一测试映射(重要)
|
||||
|
||||
### 强制要求
|
||||
- **严格对应**:每个测试文件必须严格对应一个源文件
|
||||
- **禁止多对一**:不允许一个测试文件测试多个源文件
|
||||
- **禁止一对多**:不允许一个源文件的测试分散在多个测试文件
|
||||
- **命名对应**:测试文件名必须与源文件名完全对应(除.spec.ts后缀外)
|
||||
|
||||
### 测试范围严格限制
|
||||
```typescript
|
||||
// ✅ 正确:只测试LoginService的功能
|
||||
// 文件:src/business/auth/login.service.spec.ts
|
||||
describe('LoginService', () => {
|
||||
describe('validateUser', () => {
|
||||
it('should validate user credentials', () => {
|
||||
// 只测试LoginService.validateUser方法
|
||||
// 使用Mock隔离UserRepository等外部依赖
|
||||
});
|
||||
|
||||
it('should throw error for invalid credentials', () => {
|
||||
// 测试异常情况
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('should generate valid JWT token', () => {
|
||||
// 只测试LoginService.generateToken方法
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ 错误:在LoginService测试中测试其他服务
|
||||
describe('LoginService', () => {
|
||||
it('should integrate with UserRepository', () => {
|
||||
// 错误:这是集成测试,应该移到test/integration/
|
||||
});
|
||||
|
||||
it('should work with EmailService', () => {
|
||||
// 错误:测试了EmailService的功能,违反范围限制
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🏗️ 测试分离架构(强制要求)
|
||||
|
||||
### 顶层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
|
||||
// ✅ 正确:单元测试只在源文件同目录
|
||||
// 文件位置:src/business/auth/login.service.spec.ts
|
||||
describe('LoginService Unit Tests', () => {
|
||||
// 只测试LoginService的单个方法功能
|
||||
// 使用Mock隔离所有外部依赖
|
||||
});
|
||||
|
||||
// ✅ 正确:集成测试统一在test/integration/
|
||||
// 文件位置:test/integration/auth_integration.spec.ts
|
||||
describe('Auth Integration Tests', () => {
|
||||
it('should integrate LoginService with UserRepository and TokenService', () => {
|
||||
// 测试多个模块间的真实交互
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 正确:E2E测试统一在test/e2e/
|
||||
// 文件位置:test/e2e/user_auth_e2e.spec.ts
|
||||
describe('User Authentication E2E Tests', () => {
|
||||
it('should handle complete user login flow', () => {
|
||||
// 端到端完整业务流程测试
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🎮 游戏服务器特殊测试要求
|
||||
|
||||
### WebSocket Gateway测试
|
||||
```typescript
|
||||
// ✅ 正确:完整的WebSocket测试
|
||||
// 文件:src/business/location/location_broadcast.gateway.spec.ts
|
||||
describe('LocationBroadcastGateway', () => {
|
||||
let gateway: LocationBroadcastGateway;
|
||||
let mockServer: jest.Mocked<Server>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 设置Mock服务器和依赖
|
||||
});
|
||||
|
||||
describe('handleConnection', () => {
|
||||
it('should accept valid WebSocket connection with JWT token', () => {
|
||||
// 正常连接测试
|
||||
});
|
||||
|
||||
it('should reject connection with invalid JWT token', () => {
|
||||
// 异常连接测试
|
||||
});
|
||||
|
||||
it('should handle connection when room is at capacity limit', () => {
|
||||
// 边界情况测试
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePositionUpdate', () => {
|
||||
it('should broadcast position to all room members', () => {
|
||||
// 实时通信测试
|
||||
});
|
||||
|
||||
it('should validate position data format', () => {
|
||||
// 数据验证测试
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisconnect', () => {
|
||||
it('should clean up user resources on disconnect', () => {
|
||||
// 断开连接测试
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 双模式服务测试
|
||||
```typescript
|
||||
// ✅ 正确:内存服务测试
|
||||
// 文件:src/core/users/users_memory.service.spec.ts
|
||||
describe('UsersMemoryService', () => {
|
||||
it('should create user in memory storage', () => {
|
||||
// 测试内存模式特定功能
|
||||
});
|
||||
|
||||
it('should handle concurrent access correctly', () => {
|
||||
// 测试内存模式并发处理
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 正确:数据库服务测试
|
||||
// 文件:src/core/users/users_database.service.spec.ts
|
||||
describe('UsersDatabaseService', () => {
|
||||
it('should create user in database', () => {
|
||||
// 测试数据库模式特定功能
|
||||
});
|
||||
|
||||
it('should handle database transaction correctly', () => {
|
||||
// 测试数据库事务处理
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ 正确:双模式一致性测试(集成测试)
|
||||
// 文件:test/integration/users_dual_mode_integration.spec.ts
|
||||
describe('Users Dual Mode Integration', () => {
|
||||
it('should have identical behavior for user creation', () => {
|
||||
// 测试两种模式行为一致性
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 属性测试(管理员模块)
|
||||
```typescript
|
||||
// ✅ 正确:属性测试
|
||||
// 文件:test/property/admin_property.spec.ts
|
||||
import * as fc from 'fast-check';
|
||||
|
||||
describe('AdminService Properties', () => {
|
||||
it('should handle any valid user status update', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.integer({ min: 1, max: 1000000 }), // userId
|
||||
fc.constantFrom(...Object.values(UserStatus)), // status
|
||||
async (userId, status) => {
|
||||
try {
|
||||
const result = await adminService.updateUserStatus(userId, status);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe(status);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(NotFoundException || BadRequestException);
|
||||
}
|
||||
}
|
||||
));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 📍 测试文件位置规范
|
||||
|
||||
### 正确位置
|
||||
```
|
||||
✅ 正确:测试文件与源文件同目录
|
||||
src/business/auth/
|
||||
├── auth.service.ts
|
||||
├── auth.service.spec.ts # 单元测试
|
||||
├── auth.controller.ts
|
||||
└── auth.controller.spec.ts # 单元测试
|
||||
|
||||
src/core/location_broadcast_core/
|
||||
├── location_broadcast_core.service.ts
|
||||
└── location_broadcast_core.service.spec.ts
|
||||
```
|
||||
|
||||
### 错误位置(必须修正)
|
||||
```
|
||||
❌ 错误:测试文件在单独文件夹
|
||||
src/business/auth/
|
||||
├── auth.service.ts
|
||||
├── auth.controller.ts
|
||||
└── tests/ # 错误:单独的测试文件夹
|
||||
├── auth.service.spec.ts # 应该移到上级目录
|
||||
└── auth.controller.spec.ts
|
||||
|
||||
src/business/auth/
|
||||
├── auth.service.ts
|
||||
├── auth.controller.ts
|
||||
└── __tests__/ # 错误:单独的测试文件夹
|
||||
└── auth.spec.ts # 应该拆分并移到上级目录
|
||||
```
|
||||
|
||||
## 🧪 测试执行验证(强制要求)
|
||||
|
||||
### 测试命令执行
|
||||
```bash
|
||||
# 单元测试(严格限制:只执行.spec.ts文件)
|
||||
npm run test:unit
|
||||
# 等价于: jest --testPathPattern=spec.ts --testPathIgnorePatterns="integration|e2e|performance|property"
|
||||
|
||||
# 集成测试(统一在test/integration/目录执行)
|
||||
npm run test:integration
|
||||
# 等价于: jest test/integration/
|
||||
|
||||
# E2E测试(统一在test/e2e/目录执行)
|
||||
npm run test:e2e
|
||||
# 等价于: jest test/e2e/
|
||||
|
||||
# 属性测试(统一在test/property/目录执行)
|
||||
npm run test:property
|
||||
# 等价于: jest test/property/
|
||||
|
||||
# 性能测试(统一在test/performance/目录执行)
|
||||
npm run test:performance
|
||||
# 等价于: jest test/performance/
|
||||
|
||||
# 🔥 特定文件或目录测试(步骤5专用指令)
|
||||
pnpm test (文件夹或者文件的相对地址)
|
||||
# 示例:
|
||||
pnpm test src/core/zulip_core # 测试整个zulip_core模块
|
||||
pnpm test src/core/zulip_core/services # 测试services目录
|
||||
pnpm test src/core/zulip_core/services/config_manager.service.spec.ts # 测试单个文件
|
||||
pnpm test test/integration/zulip_integration.spec.ts # 测试集成测试文件
|
||||
```
|
||||
|
||||
### 🔥 强制测试执行要求(重要)
|
||||
|
||||
**步骤5完成前必须确保所有检查范围内的测试通过**
|
||||
|
||||
#### 测试执行验证流程
|
||||
1. **识别检查范围**:确定当前检查涉及的所有模块和文件
|
||||
2. **执行范围内测试**:运行所有相关的单元测试、集成测试
|
||||
3. **修复测试失败**:解决所有测试失败问题(类型错误、逻辑错误等)
|
||||
4. **验证测试通过**:确保所有测试都能成功执行
|
||||
5. **提供测试报告**:展示测试执行结果和覆盖率
|
||||
|
||||
#### 测试失败处理原则
|
||||
```bash
|
||||
# 🔥 如果发现测试失败,必须修复后才能完成步骤5
|
||||
|
||||
# 1. 运行特定模块测试(推荐使用pnpm test指令)
|
||||
pnpm test src/core/zulip_core # 测试整个模块
|
||||
pnpm test src/core/zulip_core/services # 测试services目录
|
||||
pnpm test src/core/zulip_core/services/config_manager.service.spec.ts # 测试单个文件
|
||||
|
||||
# 2. 分析失败原因
|
||||
# - 类型错误:修正TypeScript类型定义
|
||||
# - 接口不匹配:更新接口或Mock对象
|
||||
# - 逻辑错误:修正业务逻辑实现
|
||||
# - 依赖问题:更新依赖注入或Mock配置
|
||||
|
||||
# 3. 修复后重新运行测试
|
||||
pnpm test src/core/zulip_core # 重新测试修复后的模块
|
||||
|
||||
# 4. 确保所有测试通过后才完成步骤5
|
||||
```
|
||||
|
||||
#### 测试执行成功标准
|
||||
- ✅ **零失败测试**:所有相关测试必须通过(0 failed)
|
||||
- ✅ **零错误测试**:所有测试套件必须成功运行(0 error)
|
||||
- ✅ **完整覆盖**:所有检查范围内的文件都有测试执行
|
||||
- ✅ **类型安全**:无TypeScript编译错误
|
||||
- ✅ **依赖正确**:所有Mock和依赖注入正确配置
|
||||
|
||||
#### 测试执行报告模板
|
||||
```
|
||||
## 测试执行验证报告
|
||||
|
||||
### 🧪 测试执行结果
|
||||
- 执行命令:pnpm test src/core/zulip_core
|
||||
- 测试套件:X passed, 0 failed
|
||||
- 测试用例:X passed, 0 failed
|
||||
- 覆盖率:X% statements, X% branches, X% functions, X% lines
|
||||
|
||||
### 🔧 修复的问题
|
||||
- 类型错误修复:[具体修复内容]
|
||||
- 接口更新:[具体更新内容]
|
||||
- Mock配置:[具体配置内容]
|
||||
|
||||
### ✅ 验证状态
|
||||
- 所有测试通过 ✓
|
||||
- 无编译错误 ✓
|
||||
- 依赖注入正确 ✓
|
||||
- Mock配置完整 ✓
|
||||
|
||||
**测试执行验证完成,可以进行下一步骤**
|
||||
```
|
||||
|
||||
### 测试执行顺序
|
||||
1. **第一阶段**:单元测试(快速反馈)
|
||||
2. **第二阶段**:集成测试(模块协作)
|
||||
3. **第三阶段**:E2E测试(业务流程)
|
||||
4. **第四阶段**:性能测试(系统性能)
|
||||
|
||||
### 🚨 测试执行失败处理
|
||||
如果在测试执行过程中发现失败,必须:
|
||||
1. **立即停止步骤5进程**
|
||||
2. **分析并修复所有测试失败**
|
||||
3. **重新执行完整的步骤5检查**
|
||||
4. **确保所有测试通过后才能进入步骤6**
|
||||
|
||||
## 🔍 检查执行步骤
|
||||
|
||||
1. **扫描需要测试的文件类型**
|
||||
- 识别所有.service.ts、.controller.ts、.gateway.ts等文件
|
||||
- 检查是否有对应的.spec.ts测试文件
|
||||
|
||||
2. **验证一对一测试映射**
|
||||
- 确保每个测试文件严格对应一个源文件
|
||||
- 检查测试文件命名是否正确对应
|
||||
|
||||
3. **检查测试范围限制**
|
||||
- 确保测试内容严格限于对应源文件功能
|
||||
- 识别跨文件测试和混合测试
|
||||
|
||||
4. **检查测试文件位置**
|
||||
- 确保单元测试与源文件在同一目录
|
||||
- 识别需要扁平化的测试文件夹
|
||||
|
||||
5. **分离集成测试和E2E测试**
|
||||
- 将集成测试移动到test/integration/
|
||||
- 将E2E测试移动到test/e2e/
|
||||
- 将性能测试移动到test/performance/
|
||||
- 将属性测试移动到test/property/
|
||||
|
||||
6. **游戏服务器特殊检查**
|
||||
- WebSocket Gateway的完整测试覆盖
|
||||
- 双模式服务的一致性测试
|
||||
- 属性测试的正确实现
|
||||
|
||||
7. **🔥 强制执行测试验证(关键步骤)**
|
||||
- 运行检查范围内的所有相关测试
|
||||
- 修复所有测试失败问题
|
||||
- 确保测试覆盖率达标
|
||||
- 验证测试质量和有效性
|
||||
- **只有所有测试通过才能完成步骤5**
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(创建测试文件、移动测试文件、修正测试内容、修复测试失败等),必须立即重新执行步骤5的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤5 → 🧪 强制执行测试验证 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接进入步骤6(错误做法)
|
||||
|
||||
**🚨 重要强调:纯检查步骤不更新修改记录**
|
||||
**如果检查发现测试覆盖已经符合规范,无需任何修改,则:**
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤5:测试覆盖检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||
|
||||
**🚨 步骤5完成的强制条件:**
|
||||
1. **测试文件完整性检查通过**
|
||||
2. **测试映射关系检查通过**
|
||||
3. **测试分离架构检查通过**
|
||||
4. **🔥 所有检查范围内的测试必须执行成功(零失败)**
|
||||
|
||||
**不能跳过测试执行验证环节!如果测试失败,必须修复后重新执行整个步骤5!**
|
||||
|
||||
---
|
||||
|
||||
## ✅ zulip_core模块步骤5检查完成报告
|
||||
|
||||
### 📋 检查范围
|
||||
- **模块**:src/core/zulip_core
|
||||
- **检查日期**:2026-01-12
|
||||
- **检查人员**:moyin
|
||||
|
||||
### 🧪 测试执行验证结果
|
||||
|
||||
#### 执行命令
|
||||
```bash
|
||||
npx jest src/core/zulip_core --testTimeout=15000
|
||||
```
|
||||
|
||||
#### 测试结果统计
|
||||
- **测试套件**:11 passed, 0 failed
|
||||
- **测试用例**:367 passed, 0 failed
|
||||
- **执行时间**:11.841s
|
||||
- **覆盖状态**:✅ 完整覆盖
|
||||
|
||||
#### 修复的关键问题
|
||||
1. **DynamicConfigManagerService测试失败修复**:
|
||||
- 修正了Zulip凭据初始化顺序问题
|
||||
- 修复了Mock配置的fs.existsSync行为
|
||||
- 解决了环境变量设置时机问题
|
||||
- 修正了测试用例的预期错误消息
|
||||
|
||||
2. **测试文件完整性验证**:
|
||||
- 确认所有service文件都有对应的.spec.ts测试文件
|
||||
- 验证了严格的一对一测试映射关系
|
||||
- 检查了测试文件位置的正确性
|
||||
|
||||
### 📊 测试覆盖详情
|
||||
|
||||
#### 通过的测试套件
|
||||
1. ✅ api_key_security.service.spec.ts (53 tests)
|
||||
2. ✅ config_manager.service.spec.ts (45 tests)
|
||||
3. ✅ dynamic_config_manager.service.spec.ts (32 tests)
|
||||
4. ✅ monitoring.service.spec.ts (15 tests)
|
||||
5. ✅ stream_initializer.service.spec.ts (11 tests)
|
||||
6. ✅ user_management.service.spec.ts (16 tests)
|
||||
7. ✅ user_registration.service.spec.ts (9 tests)
|
||||
8. ✅ zulip_account.service.spec.ts (26 tests)
|
||||
9. ✅ zulip_client.service.spec.ts (19 tests)
|
||||
10. ✅ zulip_client_pool.service.spec.ts (23 tests)
|
||||
11. ✅ zulip_core.module.spec.ts (118 tests)
|
||||
|
||||
#### 测试质量验证
|
||||
- **单元测试隔离**:✅ 所有测试使用Mock隔离外部依赖
|
||||
- **测试范围限制**:✅ 每个测试文件严格测试对应的单个服务
|
||||
- **错误处理覆盖**:✅ 包含完整的异常情况测试
|
||||
- **边界条件测试**:✅ 覆盖各种边界和异常场景
|
||||
|
||||
### 🔧 修改记录
|
||||
|
||||
#### 文件修改详情
|
||||
- **修改文件**:src/core/zulip_core/services/dynamic_config_manager.service.spec.ts
|
||||
- **修改时间**:2026-01-12
|
||||
- **修改人员**:moyin
|
||||
- **修改内容**:
|
||||
- 修正了beforeEach中环境变量设置顺序
|
||||
- 修复了无凭据测试的服务实例创建
|
||||
- 修正了fs.existsSync的Mock行为
|
||||
- 更新了错误消息的预期值
|
||||
|
||||
### ✅ 验证状态确认
|
||||
|
||||
- **测试文件完整性**:✅ 通过
|
||||
- **一对一测试映射**:✅ 通过
|
||||
- **测试分离架构**:✅ 通过
|
||||
- **测试执行验证**:✅ 通过(0失败,367通过)
|
||||
- **类型安全检查**:✅ 通过
|
||||
- **依赖注入配置**:✅ 通过
|
||||
|
||||
### 🎯 步骤5完成确认
|
||||
|
||||
**zulip_core模块的步骤5测试覆盖检查已完成,所有强制条件均已满足:**
|
||||
|
||||
1. ✅ 测试文件完整性检查通过
|
||||
2. ✅ 测试映射关系检查通过
|
||||
3. ✅ 测试分离架构检查通过
|
||||
4. ✅ 所有测试执行成功(零失败)
|
||||
|
||||
**可以进入下一步骤的开发工作。**
|
||||
|
||||
---
|
||||
|
||||
## ✅ Zulip模块完整步骤5检查完成报告
|
||||
|
||||
### 📋 检查范围
|
||||
- **模块**:Zulip相关所有模块
|
||||
- src/core/zulip_core (12个源文件)
|
||||
- src/core/db/zulip_accounts (5个源文件)
|
||||
- src/business/zulip (13个源文件)
|
||||
- **检查日期**:2026-01-12
|
||||
- **检查人员**:moyin
|
||||
|
||||
### 🧪 测试执行验证结果
|
||||
|
||||
#### 最终测试状态
|
||||
- **总测试套件**:30个
|
||||
- **通过测试套件**:30个 ✅
|
||||
- **失败测试套件**:0个 ✅
|
||||
- **总测试用例**:907个
|
||||
- **通过测试用例**:907个 ✅
|
||||
- **失败测试用例**:0个 ✅
|
||||
|
||||
#### 执行的测试命令
|
||||
```bash
|
||||
# 核心模块测试
|
||||
pnpm test src/core/zulip_core
|
||||
# 结果:12个测试套件通过,394个测试通过
|
||||
|
||||
# 数据库模块测试
|
||||
pnpm test src/core/db/zulip_accounts
|
||||
# 结果:5个测试套件通过,156个测试通过
|
||||
|
||||
# 业务模块测试
|
||||
pnpm test src/business/zulip
|
||||
# 结果:13个测试套件通过,357个测试通过
|
||||
```
|
||||
|
||||
### 🔧 修复的测试问题
|
||||
|
||||
#### 1. chat.controller.spec.ts
|
||||
- **问题**:错误处理测试期望HttpException但收到Error
|
||||
- **修复**:修改mock实现抛出HttpException而不是Error
|
||||
- **状态**:✅ 已修复
|
||||
- **修改记录**:已更新文件头部修改记录
|
||||
|
||||
#### 2. zulip.service.spec.ts
|
||||
- **问题**:消息内容断言失败,实际内容包含额外的游戏消息ID
|
||||
- **修复**:使用expect.stringContaining()匹配包含原始内容的字符串
|
||||
- **状态**:✅ 已修复
|
||||
- **修改记录**:已更新文件头部修改记录
|
||||
|
||||
#### 3. zulip_accounts.controller.spec.ts
|
||||
- **问题**:日志记录测试中多次调用的参数期望不匹配
|
||||
- **修复**:使用toHaveBeenNthCalledWith()精确匹配特定调用的参数
|
||||
- **状态**:✅ 已修复
|
||||
- **修改记录**:已更新文件头部修改记录
|
||||
|
||||
### 📊 测试覆盖详情
|
||||
|
||||
#### 核心模块 (src/core/zulip_core)
|
||||
✅ **完整覆盖** - 所有12个源文件都有对应的测试文件
|
||||
- api_key_security.service.spec.ts
|
||||
- config_manager.service.spec.ts
|
||||
- dynamic_config_manager.service.spec.ts
|
||||
- monitoring.service.spec.ts
|
||||
- stream_initializer.service.spec.ts
|
||||
- user_management.service.spec.ts
|
||||
- user_registration.service.spec.ts
|
||||
- zulip_account.service.spec.ts
|
||||
- zulip_client.service.spec.ts
|
||||
- zulip_client_pool.service.spec.ts
|
||||
- zulip_core.module.spec.ts
|
||||
- zulip_event_queue.service.spec.ts
|
||||
|
||||
#### 数据库模块 (src/core/db/zulip_accounts)
|
||||
✅ **完整覆盖** - 所有5个源文件都有对应的测试文件
|
||||
- zulip_accounts.repository.spec.ts
|
||||
- zulip_accounts_memory.repository.spec.ts
|
||||
- zulip_accounts.entity.spec.ts
|
||||
- zulip_accounts.module.spec.ts
|
||||
- zulip_accounts.service.spec.ts
|
||||
|
||||
#### 业务模块 (src/business/zulip)
|
||||
✅ **完整覆盖** - 所有13个源文件都有对应的测试文件
|
||||
- chat.controller.spec.ts
|
||||
- clean_websocket.gateway.spec.ts
|
||||
- dynamic_config.controller.spec.ts
|
||||
- websocket_docs.controller.spec.ts
|
||||
- websocket_openapi.controller.spec.ts
|
||||
- websocket_test.controller.spec.ts
|
||||
- zulip.service.spec.ts
|
||||
- zulip_accounts.controller.spec.ts
|
||||
- services/message_filter.service.spec.ts
|
||||
- services/session_cleanup.service.spec.ts
|
||||
- services/session_manager.service.spec.ts
|
||||
- services/zulip_accounts_business.service.spec.ts
|
||||
- services/zulip_event_processor.service.spec.ts
|
||||
|
||||
### 🎯 测试质量验证
|
||||
|
||||
#### 功能覆盖率
|
||||
- **登录流程**: ✅ 完整覆盖(包括属性测试)
|
||||
- **消息发送**: ✅ 完整覆盖(包括属性测试)
|
||||
- **位置更新**: ✅ 完整覆盖(包括属性测试)
|
||||
- **会话管理**: ✅ 完整覆盖
|
||||
- **配置管理**: ✅ 完整覆盖
|
||||
- **错误处理**: ✅ 完整覆盖
|
||||
- **WebSocket集成**: ✅ 完整覆盖
|
||||
- **数据库操作**: ✅ 完整覆盖
|
||||
|
||||
#### 属性测试覆盖
|
||||
- **Property 1**: 玩家登录流程完整性 ✅
|
||||
- **Property 3**: 消息发送流程完整性 ✅
|
||||
- **Property 6**: 位置更新和上下文注入 ✅
|
||||
- **Property 7**: 内容安全和频率控制 ✅
|
||||
|
||||
#### 测试架构验证
|
||||
- **单元测试隔离**: ✅ 所有测试使用Mock隔离外部依赖
|
||||
- **一对一测试映射**: ✅ 每个测试文件严格对应一个源文件
|
||||
- **测试范围限制**: ✅ 测试内容严格限于对应源文件功能
|
||||
- **错误处理覆盖**: ✅ 包含完整的异常情况测试
|
||||
- **边界条件测试**: ✅ 覆盖各种边界和异常场景
|
||||
|
||||
### 🔧 修改文件记录
|
||||
|
||||
#### 修改的测试文件
|
||||
1. **src/business/zulip/chat.controller.spec.ts**
|
||||
- 修改时间:2026-01-12
|
||||
- 修改人员:moyin
|
||||
- 修改内容:修复错误处理测试中的异常类型期望
|
||||
|
||||
2. **src/business/zulip/zulip.service.spec.ts**
|
||||
- 修改时间:2026-01-12
|
||||
- 修改人员:moyin
|
||||
- 修改内容:修复消息内容断言,使用stringContaining匹配
|
||||
|
||||
3. **src/business/zulip/zulip_accounts.controller.spec.ts**
|
||||
- 修改时间:2026-01-12
|
||||
- 修改人员:moyin
|
||||
- 修改内容:修复日志记录测试的参数期望
|
||||
|
||||
### ✅ 最终验证状态确认
|
||||
|
||||
- **测试文件完整性**:✅ 通过(30/30文件有测试)
|
||||
- **一对一测试映射**:✅ 通过(严格对应关系)
|
||||
- **测试分离架构**:✅ 通过(单元测试在源文件同目录)
|
||||
- **测试执行验证**:✅ 通过(907个测试全部通过,0失败)
|
||||
- **类型安全检查**:✅ 通过(无TypeScript编译错误)
|
||||
- **依赖注入配置**:✅ 通过(Mock配置正确)
|
||||
|
||||
### 🎯 步骤5完成确认
|
||||
|
||||
**Zulip模块的步骤5测试覆盖检查已完成,所有强制条件均已满足:**
|
||||
|
||||
1. ✅ 测试文件完整性检查通过(100%覆盖率)
|
||||
2. ✅ 测试映射关系检查通过(严格一对一映射)
|
||||
3. ✅ 测试分离架构检查通过(单元测试正确位置)
|
||||
4. ✅ 所有测试执行成功(907个测试通过,0失败)
|
||||
|
||||
**🎉 Zulip模块具备完整的测试覆盖率和高质量的测试代码,可以进入下一步骤的开发工作。**
|
||||
@@ -1,350 +0,0 @@
|
||||
# 步骤6:功能文档生成
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
生成和维护功能模块的README文档,确保文档内容完整、准确、实用。
|
||||
|
||||
## 📚 README文档结构
|
||||
|
||||
### 必须包含的章节
|
||||
每个功能模块文件夹都必须有README.md文档,包含以下结构:
|
||||
|
||||
```markdown
|
||||
# [模块名称] [中文描述]
|
||||
|
||||
[模块名称] 是 [一段话总结文件夹的整体功能和作用,说明其在项目中的定位和价值]。
|
||||
|
||||
## 对外提供的接口
|
||||
|
||||
### create()
|
||||
创建新用户记录,支持数据验证和唯一性检查。
|
||||
|
||||
### findByEmail()
|
||||
根据邮箱地址查询用户,用于登录验证和账户找回。
|
||||
|
||||
## 对外API接口(如适用)
|
||||
|
||||
### POST /api/auth/login
|
||||
用户登录接口,支持用户名/邮箱/手机号多种方式登录。
|
||||
|
||||
### GET /api/users/:id
|
||||
根据用户ID获取用户详细信息。
|
||||
|
||||
## WebSocket事件接口(如适用)
|
||||
|
||||
### 'connection'
|
||||
客户端建立WebSocket连接,需要提供JWT认证token。
|
||||
|
||||
### 'position_update'
|
||||
接收客户端位置更新,广播给房间内其他用户。
|
||||
|
||||
## 使用的项目内部依赖
|
||||
|
||||
### UserStatus (来自 business/user-mgmt/enums/user-status.enum)
|
||||
用户状态枚举,定义用户的激活、禁用、待验证等状态值。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 双存储模式支持
|
||||
- 数据库模式:使用TypeORM连接MySQL,适用于生产环境
|
||||
- 内存模式:使用Map存储,适用于开发测试和故障降级
|
||||
|
||||
## 潜在风险
|
||||
|
||||
### 内存模式数据丢失风险
|
||||
- 内存存储在应用重启后数据会丢失
|
||||
- 建议仅在开发测试环境使用
|
||||
```
|
||||
|
||||
## 🔌 对外接口文档
|
||||
|
||||
### 公共方法描述
|
||||
每个公共方法必须有一句话功能说明:
|
||||
|
||||
```markdown
|
||||
## 对外提供的接口
|
||||
|
||||
### create(userData: CreateUserDto): Promise<User>
|
||||
创建新用户记录,支持数据验证和唯一性检查。
|
||||
|
||||
### findById(id: string): Promise<User>
|
||||
根据用户ID查询用户信息,用于身份验证和数据获取。
|
||||
|
||||
### updateStatus(id: string, status: UserStatus): Promise<User>
|
||||
更新用户状态,支持激活、禁用、待验证等状态切换。
|
||||
|
||||
### delete(id: string): Promise<void>
|
||||
删除用户记录及相关数据,执行软删除保留审计信息。
|
||||
|
||||
### findByEmail(email: string): Promise<User>
|
||||
根据邮箱地址查询用户,用于登录验证和账户找回。
|
||||
```
|
||||
|
||||
## 🌐 API接口文档(Business模块)
|
||||
|
||||
### HTTP API接口
|
||||
如果business模块开放了可访问的API,必须列出所有API:
|
||||
|
||||
```markdown
|
||||
## 对外API接口
|
||||
|
||||
### POST /api/auth/login
|
||||
用户登录接口,支持用户名/邮箱/手机号多种方式登录。
|
||||
|
||||
### GET /api/users/:id
|
||||
根据用户ID获取用户详细信息。
|
||||
|
||||
### PUT /api/users/:id/status
|
||||
更新指定用户的状态(激活/禁用/待验证)。
|
||||
|
||||
### DELETE /api/users/:id
|
||||
删除指定用户账户及相关数据。
|
||||
|
||||
### GET /api/users/search
|
||||
根据条件搜索用户,支持邮箱、用户名、状态等筛选。
|
||||
|
||||
### POST /api/users/batch
|
||||
批量创建用户,支持Excel导入和数据验证。
|
||||
```
|
||||
|
||||
## 🔌 WebSocket接口文档(Gateway模块)
|
||||
|
||||
### WebSocket事件接口
|
||||
Gateway模块需要详细的WebSocket事件文档:
|
||||
|
||||
```markdown
|
||||
## WebSocket事件接口
|
||||
|
||||
### 'connection'
|
||||
客户端建立WebSocket连接,需要提供JWT认证token。
|
||||
- 输入: `{ token: string }`
|
||||
- 输出: 连接成功确认
|
||||
|
||||
### 'position_update'
|
||||
接收客户端位置更新,广播给房间内其他用户。
|
||||
- 输入: `{ x: number, y: number, timestamp: number }`
|
||||
- 输出: 广播给房间成员
|
||||
|
||||
### 'join_room'
|
||||
用户加入游戏房间,建立实时通信连接。
|
||||
- 输入: `{ roomId: string }`
|
||||
- 输出: `{ success: boolean, members: string[] }`
|
||||
|
||||
### 'chat_message'
|
||||
处理聊天消息,支持Zulip集成和消息过滤。
|
||||
- 输入: `{ message: string, roomId: string }`
|
||||
- 输出: 广播给房间成员或转发到Zulip
|
||||
|
||||
### 'disconnect'
|
||||
客户端断开连接,清理相关资源和通知其他用户。
|
||||
- 输入: 无
|
||||
- 输出: 通知房间其他成员
|
||||
```
|
||||
|
||||
## 🔗 内部依赖分析
|
||||
|
||||
### 依赖列表格式
|
||||
```markdown
|
||||
## 使用的项目内部依赖
|
||||
|
||||
### UserStatus (来自 business/user-mgmt/enums/user-status.enum)
|
||||
用户状态枚举,定义用户的激活、禁用、待验证等状态值。
|
||||
|
||||
### CreateUserDto (本模块)
|
||||
用户创建数据传输对象,提供完整的数据验证规则和类型定义。
|
||||
|
||||
### LoggerService (来自 core/utils/logger)
|
||||
日志服务,用于记录用户操作和系统事件。
|
||||
|
||||
### CacheService (来自 core/redis)
|
||||
缓存服务,用于提升用户查询性能和会话管理。
|
||||
|
||||
### EmailService (来自 core/utils/email)
|
||||
邮件服务,用于发送用户注册验证和通知邮件。
|
||||
```
|
||||
|
||||
## ⭐ 核心特性识别
|
||||
|
||||
### 技术特性
|
||||
```markdown
|
||||
## 核心特性
|
||||
|
||||
### 双存储模式支持
|
||||
- 数据库模式:使用TypeORM连接MySQL,适用于生产环境
|
||||
- 内存模式:使用Map存储,适用于开发测试和故障降级
|
||||
- 动态模块配置:通过UsersModule.forDatabase()和forMemory()灵活切换
|
||||
- 自动检测:根据环境变量自动选择存储模式
|
||||
|
||||
### 实时通信能力
|
||||
- WebSocket支持:基于Socket.IO的实时双向通信
|
||||
- 房间管理:支持用户加入/离开游戏房间
|
||||
- 位置广播:实时广播用户位置更新给房间成员
|
||||
- 连接管理:自动处理连接断开和重连机制
|
||||
|
||||
### 数据完整性保障
|
||||
- 唯一性约束检查:用户名、邮箱、手机号、GitHub ID
|
||||
- 数据验证:使用class-validator进行输入验证
|
||||
- 事务支持:批量操作支持回滚机制
|
||||
- 双模式一致性:确保内存模式和数据库模式行为一致
|
||||
|
||||
### 性能优化与监控
|
||||
- 查询优化:使用索引和查询缓存
|
||||
- 批量操作:支持批量创建和更新
|
||||
- 内存缓存:热点数据缓存机制
|
||||
- 性能监控:WebSocket连接数、消息处理延迟等指标
|
||||
- 属性测试:使用fast-check进行随机化测试
|
||||
```
|
||||
|
||||
## ⚠️ 潜在风险评估
|
||||
|
||||
### 风险分类和描述
|
||||
```markdown
|
||||
## 潜在风险
|
||||
|
||||
### 内存模式数据丢失风险
|
||||
- 内存存储在应用重启后数据会丢失
|
||||
- 不适用于生产环境的持久化需求
|
||||
- 建议仅在开发测试环境使用
|
||||
- 缓解措施:提供数据导出/导入功能
|
||||
|
||||
### WebSocket连接管理风险
|
||||
- 大量并发连接可能导致内存泄漏
|
||||
- 网络不稳定时连接频繁断开重连
|
||||
- 房间成员过多时广播性能下降
|
||||
- 缓解措施:连接数限制、心跳检测、分片广播
|
||||
|
||||
### 实时通信性能风险
|
||||
- 高频位置更新可能导致服务器压力
|
||||
- 消息广播延迟影响游戏体验
|
||||
- WebSocket消息丢失或重复
|
||||
- 缓解措施:消息限流、优先级队列、消息确认机制
|
||||
|
||||
### 双模式一致性风险
|
||||
- 内存模式和数据库模式行为可能不一致
|
||||
- 模式切换时数据同步问题
|
||||
- 测试覆盖不完整导致隐藏差异
|
||||
- 缓解措施:统一接口抽象、完整的对比测试
|
||||
|
||||
### 安全风险
|
||||
- WebSocket连接缺少足够的认证验证
|
||||
- 用户位置信息泄露风险
|
||||
- 管理员权限过度集中
|
||||
- 缓解措施:JWT认证、数据脱敏、权限细分
|
||||
```
|
||||
|
||||
## 🎮 游戏服务器特殊文档要求
|
||||
|
||||
### 实时通信协议说明
|
||||
```markdown
|
||||
### 实时通信协议
|
||||
- 协议类型:WebSocket (Socket.IO)
|
||||
- 认证方式:JWT Token验证
|
||||
- 心跳间隔:10秒
|
||||
- 超时设置:30秒无响应自动断开
|
||||
- 重连策略:指数退避,最大重试5次
|
||||
```
|
||||
|
||||
### 双模式切换指南
|
||||
```markdown
|
||||
### 双模式切换指南
|
||||
- 环境变量:`STORAGE_MODE=database|memory`
|
||||
- 切换命令:`npm run switch:database` 或 `npm run switch:memory`
|
||||
- 数据迁移:提供内存到数据库的数据导出/导入工具
|
||||
- 性能对比:内存模式响应时间<1ms,数据库模式<10ms
|
||||
```
|
||||
|
||||
### 属性测试策略说明
|
||||
```markdown
|
||||
### 属性测试策略
|
||||
- 测试框架:fast-check
|
||||
- 测试范围:管理员操作、用户状态变更、权限验证
|
||||
- 随机化参数:用户ID(1-1000000)、状态枚举、权限级别
|
||||
- 执行次数:每个属性测试运行1000次随机用例
|
||||
- 失败处理:自动收集失败用例,生成最小化复现案例
|
||||
```
|
||||
|
||||
## 📝 文档质量标准
|
||||
|
||||
### 内容质量要求
|
||||
- **准确性**:所有信息必须与代码实现一致
|
||||
- **完整性**:覆盖所有公共接口和重要功能
|
||||
- **简洁性**:每个说明控制在一句话内,突出核心要点
|
||||
- **实用性**:提供对开发者有价值的信息和建议
|
||||
|
||||
### 语言表达规范
|
||||
- 使用中文进行描述,专业术语可保留英文
|
||||
- 语言简洁明了,避免冗长的句子
|
||||
- 统一术语使用,保持前后一致
|
||||
- 避免主观评价,客观描述功能和特性
|
||||
|
||||
## 🔍 检查执行步骤
|
||||
|
||||
1. **检查README文件存在性**
|
||||
- 确保每个功能模块文件夹都有README.md
|
||||
- 检查文档结构是否完整
|
||||
|
||||
2. **验证对外接口文档**
|
||||
- 列出所有公共方法
|
||||
- 为每个方法提供一句话功能说明
|
||||
- 确保接口描述准确
|
||||
|
||||
3. **检查API接口文档**
|
||||
- 如果是business模块且开放API,必须列出所有API
|
||||
- 每个API提供一句话功能说明
|
||||
- 包含请求方法和路径
|
||||
|
||||
4. **检查WebSocket接口文档**
|
||||
- Gateway模块必须详细说明WebSocket事件
|
||||
- 包含输入输出格式
|
||||
- 说明事件处理逻辑
|
||||
|
||||
5. **验证内部依赖分析**
|
||||
- 列出所有项目内部依赖
|
||||
- 说明每个依赖的用途
|
||||
- 确保依赖关系准确
|
||||
|
||||
6. **检查核心特性描述**
|
||||
- 识别技术特性、功能特性、质量特性
|
||||
- 突出游戏服务器特殊特性
|
||||
- 描述双模式、实时通信等特点
|
||||
|
||||
7. **评估潜在风险**
|
||||
- 识别技术风险、业务风险、运维风险、安全风险
|
||||
- 提供风险缓解措施
|
||||
- 特别关注游戏服务器特有风险
|
||||
|
||||
8. **验证文档与代码一致性**
|
||||
- 确保文档内容与实际代码实现一致
|
||||
- 检查接口签名、参数类型等准确性
|
||||
- 验证特性描述的真实性
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(创建README文件、更新文档内容、修正接口描述等),必须立即重新执行步骤6的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤6 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接结束检查(错误做法)
|
||||
|
||||
**🚨 重要强调:纯检查步骤不更新修改记录**
|
||||
**如果检查发现功能文档已经符合规范,无需任何修改,则:**
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤6:功能文档检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||
|
||||
**不能跳过重新检查环节!**
|
||||
@@ -1,774 +0,0 @@
|
||||
# 步骤7:代码提交
|
||||
|
||||
## ⚠️ 执行前必读规范
|
||||
|
||||
**🔥 重要:在执行本步骤之前,AI必须先完整阅读同级目录下的 `README.md` 文件!**
|
||||
|
||||
该README文件包含:
|
||||
- 🎯 执行前准备和用户信息收集要求
|
||||
- 🔄 强制执行原则和分步执行流程
|
||||
- 🔥 修改后立即重新执行当前步骤的强制规则
|
||||
- 📝 文件修改记录规范和版本号递增规则
|
||||
- 🧪 测试文件调试规范和测试指令使用规范
|
||||
- 🚨 全局约束和游戏服务器特殊要求
|
||||
|
||||
**不阅读README直接执行步骤将导致执行不规范,违反项目要求!**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 检查目标
|
||||
完成代码修改后的规范化提交流程,确保代码变更记录清晰、分支管理规范、提交信息符合项目标准。
|
||||
|
||||
## 📋 执行前置条件
|
||||
- 已完成前6个步骤的代码检查和修改
|
||||
- 所有修改的文件已更新修改记录和版本信息
|
||||
- 代码能够正常运行且通过测试
|
||||
|
||||
## 🚨 协作规范和范围控制
|
||||
|
||||
### 绝对禁止的操作
|
||||
**以下操作严格禁止,违反将影响其他AI的工作:**
|
||||
|
||||
1. **禁止暂存范围外代码**
|
||||
```bash
|
||||
# ❌ 绝对禁止
|
||||
git stash push [范围外文件]
|
||||
git stash push -m "消息" [范围外文件]
|
||||
```
|
||||
|
||||
2. **禁止重置范围外代码**
|
||||
```bash
|
||||
# ❌ 绝对禁止
|
||||
git reset HEAD [范围外文件]
|
||||
git checkout -- [范围外文件]
|
||||
```
|
||||
|
||||
3. **禁止移动或隐藏范围外代码**
|
||||
```bash
|
||||
# ❌ 绝对禁止
|
||||
git mv [范围外文件] [其他位置]
|
||||
git rm [范围外文件]
|
||||
```
|
||||
|
||||
### 协作原则
|
||||
- **范围外代码必须保持原状**:其他AI需要处理这些代码
|
||||
- **只处理自己的范围**:严格按照检查任务的文件夹范围执行
|
||||
- **不影响其他工作流**:任何操作都不能影响其他AI的检查任务
|
||||
|
||||
## 🔍 Git变更检查与校验
|
||||
|
||||
### 1. 检查Git状态和变更内容
|
||||
```bash
|
||||
# 查看当前工作区状态
|
||||
git status
|
||||
|
||||
# 查看具体变更内容
|
||||
git diff
|
||||
|
||||
# 查看已暂存的变更
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
### 2. 文件修改记录校验
|
||||
**重要**:检查每个修改文件的头部信息是否与实际修改内容一致
|
||||
|
||||
#### 校验内容包括:
|
||||
- **修改记录**:最新的修改记录是否准确描述了本次变更
|
||||
- **修改类型**:记录的修改类型(代码规范优化、功能新增等)是否与实际修改匹配
|
||||
- **修改者信息**:是否使用了正确的用户名称
|
||||
- **修改日期**:是否使用了用户提供的真实日期
|
||||
- **版本号**:是否按照规则正确递增
|
||||
- **@lastModified**:是否更新为当前修改日期
|
||||
|
||||
#### 校验方法:
|
||||
1. 逐个检查修改文件的头部注释
|
||||
2. 对比git diff显示的实际修改内容
|
||||
3. 确认修改记录描述与实际变更一致
|
||||
4. 如发现不一致,立即修正文件头部信息
|
||||
|
||||
### 3. 修改记录不一致的处理
|
||||
如果发现文件头部的修改记录与实际修改内容不符:
|
||||
|
||||
```typescript
|
||||
// ❌ 错误示例:记录说是"功能新增",但实际只是代码清理
|
||||
/**
|
||||
* 最近修改:
|
||||
* - 2024-01-12: 功能新增 - 添加新的用户验证功能 (修改者: 张三)
|
||||
*/
|
||||
// 实际修改:只是删除了未使用的导入和注释优化
|
||||
|
||||
// ✅ 正确修正:
|
||||
/**
|
||||
* 最近修改:
|
||||
* - 2024-01-12: 代码规范优化 - 清理未使用导入和优化注释 (修改者: 张三)
|
||||
*/
|
||||
```
|
||||
|
||||
## 🌿 分支管理规范
|
||||
|
||||
### 🔥 重要原则:严格范围限制
|
||||
**🚨 绝对禁止:不得暂存、提交或以任何方式处理检查范围外的代码!**
|
||||
|
||||
- ✅ **正确做法**:只提交当前检查任务涉及的文件和文件夹
|
||||
- ❌ **严格禁止**:提交其他模块、其他开发者负责的文件
|
||||
- ❌ **严格禁止**:使用git stash暂存其他范围的代码
|
||||
- ❌ **严格禁止**:以任何方式移动、隐藏或处理范围外的代码
|
||||
- ⚠️ **检查要求**:提交前必须确认所有变更文件都在当前检查范围内
|
||||
- 🔥 **协作原则**:其他范围的代码必须保持原状,供其他AI处理
|
||||
|
||||
### 分支命名规范
|
||||
根据修改类型和检查范围创建对应的分支:
|
||||
|
||||
```bash
|
||||
# 代码规范优化分支(指定检查范围)
|
||||
feature/code-standard-[模块名称]-[日期]
|
||||
# 示例:feature/code-standard-auth-20240112
|
||||
# 示例:feature/code-standard-zulip-20240112
|
||||
|
||||
# Bug修复分支(指定模块)
|
||||
fix/[模块名称]-[具体问题描述]
|
||||
# 示例:fix/auth-login-validation-issue
|
||||
# 示例:fix/zulip-message-handling-bug
|
||||
|
||||
# 功能新增分支(指定模块)
|
||||
feature/[模块名称]-[功能名称]
|
||||
# 示例:feature/auth-multi-factor-authentication
|
||||
# 示例:feature/zulip-message-encryption
|
||||
|
||||
# 重构分支(指定模块)
|
||||
refactor/[模块名称]-[重构内容]
|
||||
# 示例:refactor/auth-service-architecture
|
||||
# 示例:refactor/zulip-websocket-handler
|
||||
|
||||
# 性能优化分支(指定模块)
|
||||
perf/[模块名称]-[优化内容]
|
||||
# 示例:perf/auth-token-validation
|
||||
# 示例:perf/zulip-message-processing
|
||||
|
||||
# 文档更新分支(指定范围)
|
||||
docs/[模块名称]-[文档类型]
|
||||
# 示例:docs/auth-api-documentation
|
||||
# 示例:docs/zulip-integration-guide
|
||||
```
|
||||
|
||||
### 创建和切换分支
|
||||
```bash
|
||||
# 🔥 重要:在当前分支基础上创建新分支(不切换到主分支)
|
||||
# 查看当前分支状态
|
||||
git status
|
||||
git branch
|
||||
|
||||
# 直接在当前分支基础上创建并切换到新分支(包含检查范围标识)
|
||||
git checkout -b feature/code-standard-[模块名称]-[日期]
|
||||
|
||||
# 示例:如果当前检查auth模块
|
||||
git checkout -b feature/code-standard-auth-20240112
|
||||
|
||||
# 示例:如果当前检查zulip模块
|
||||
git checkout -b feature/code-standard-zulip-20240112
|
||||
```
|
||||
|
||||
### 🔍 提交前范围检查
|
||||
在执行任何git操作前,必须进行范围检查:
|
||||
|
||||
```bash
|
||||
# 1. 查看当前变更的文件
|
||||
git status
|
||||
|
||||
# 2. 检查变更文件是否都在检查范围内
|
||||
git diff --name-only
|
||||
|
||||
# 3. 🚨 重要:如果发现范围外的文件,绝对不能暂存或提交!
|
||||
# 正确做法:只添加范围内的文件,忽略范围外的文件
|
||||
git add [范围内的具体文件路径]
|
||||
|
||||
# 4. ❌ 错误做法:不要使用以下命令处理范围外文件
|
||||
# git stash push [范围外文件] # 禁止!会影响其他AI
|
||||
# git reset HEAD [范围外文件] # 禁止!会影响其他AI
|
||||
# git add -i # 谨慎使用,容易误选范围外文件
|
||||
```
|
||||
|
||||
### 📂 检查范围示例
|
||||
|
||||
#### 正确的范围控制
|
||||
```bash
|
||||
# 如果检查任务是 "auth 模块代码规范优化"
|
||||
# ✅ 应该包含的文件:
|
||||
src/business/auth/
|
||||
src/core/auth/
|
||||
test/business/auth/
|
||||
test/core/auth/
|
||||
docs/auth/
|
||||
|
||||
# ❌ 不应该包含的文件:
|
||||
src/business/zulip/ # 其他模块
|
||||
src/business/user-mgmt/ # 其他模块
|
||||
client/ # 前端代码
|
||||
config/ # 配置文件(除非明确要求)
|
||||
```
|
||||
|
||||
#### 范围检查命令
|
||||
```bash
|
||||
# 检查当前变更是否超出范围
|
||||
git diff --name-only | grep -v "^src/business/auth/" | grep -v "^test/.*auth" | grep -v "^docs/.*auth"
|
||||
|
||||
# 如果上述命令有输出,说明存在范围外的文件,需要排除
|
||||
```
|
||||
|
||||
## 📝 提交信息规范
|
||||
|
||||
### 提交类型映射
|
||||
根据实际修改内容选择正确的提交类型:
|
||||
|
||||
| 修改内容 | 提交类型 | 示例 |
|
||||
|---------|---------|------|
|
||||
| 命名规范调整、注释优化、代码清理 | `style` | `style:统一TypeScript代码风格和注释规范` |
|
||||
| 清理未使用代码、优化导入 | `refactor` | `refactor:清理未使用的导入和死代码` |
|
||||
| 添加新功能、新方法 | `feat` | `feat:添加用户身份验证功能` |
|
||||
| 修复Bug、错误处理 | `fix` | `fix:修复用户登录时的并发问题` |
|
||||
| 性能改进、算法优化 | `perf` | `perf:优化数据库查询性能` |
|
||||
| 代码结构调整、重构 | `refactor` | `refactor:重构用户管理服务架构` |
|
||||
| 添加或修改测试 | `test` | `test:添加用户服务单元测试` |
|
||||
| 更新文档、README | `docs` | `docs:更新API接口文档` |
|
||||
| API接口相关 | `api` | `api:添加用户信息查询接口` |
|
||||
| 数据库相关 | `db` | `db:创建用户表结构` |
|
||||
| WebSocket相关 | `websocket` | `websocket:实现实时消息推送` |
|
||||
| 认证授权相关 | `auth` | `auth:实现JWT身份验证机制` |
|
||||
| 配置文件相关 | `config` | `config:添加Redis缓存配置` |
|
||||
|
||||
### 提交信息格式
|
||||
```bash
|
||||
<类型>(<范围>):<简短描述>
|
||||
|
||||
范围:<具体的文件/文件夹范围>
|
||||
[可选的详细描述]
|
||||
|
||||
[可选的关联信息]
|
||||
```
|
||||
|
||||
### 提交信息示例
|
||||
|
||||
#### 单一类型修改(明确范围)
|
||||
```bash
|
||||
# 代码规范优化
|
||||
git commit -m "style(auth):统一命名规范和注释格式
|
||||
|
||||
范围:src/business/auth/, src/core/auth/
|
||||
- 调整文件和变量命名符合项目规范
|
||||
- 优化注释格式和内容完整性
|
||||
- 清理代码格式和缩进问题"
|
||||
|
||||
# Bug修复
|
||||
git commit -m "fix(zulip):修复消息处理时的并发问题
|
||||
|
||||
范围:src/business/zulip/services/
|
||||
- 修复消息队列处理逻辑错误
|
||||
- 添加并发控制机制
|
||||
- 优化错误提示信息"
|
||||
|
||||
# 功能新增
|
||||
git commit -m "feat(auth):实现多因素认证系统
|
||||
|
||||
范围:src/business/auth/, src/core/auth/
|
||||
- 添加TOTP验证支持
|
||||
- 实现短信验证功能
|
||||
- 支持备用验证码"
|
||||
```
|
||||
|
||||
#### 多文件相关修改(明确范围)
|
||||
```bash
|
||||
git commit -m "refactor(user-mgmt):重构用户管理模块架构
|
||||
|
||||
范围:src/business/user-mgmt/, src/core/db/users/
|
||||
涉及文件:
|
||||
- src/business/user-mgmt/user.service.ts
|
||||
- src/business/user-mgmt/user.controller.ts
|
||||
- src/core/db/users/users.repository.ts
|
||||
|
||||
主要改进:
|
||||
- 分离业务逻辑和数据访问层
|
||||
- 优化服务接口设计
|
||||
- 提升代码可维护性"
|
||||
```
|
||||
|
||||
## 🔄 提交执行流程
|
||||
|
||||
### 🔥 范围控制原则
|
||||
**🚨 在执行任何提交操作前,必须确保所有变更文件都在当前检查任务的范围内!**
|
||||
**🚨 绝对禁止暂存、重置或以任何方式处理范围外的代码!**
|
||||
|
||||
### 1. 范围检查与文件筛选
|
||||
```bash
|
||||
# 第一步:查看所有变更文件
|
||||
git status
|
||||
git diff --name-only
|
||||
|
||||
# 第二步:识别范围内和范围外的文件
|
||||
# 假设当前检查任务是 "auth 模块优化"
|
||||
# 范围内文件示例:
|
||||
# - src/business/auth/
|
||||
# - src/core/auth/
|
||||
# - test/business/auth/
|
||||
# - test/core/auth/
|
||||
# - docs/auth/
|
||||
|
||||
# 第三步:🚨 重要 - 只添加范围内的文件,绝对不处理范围外文件
|
||||
git add src/business/auth/
|
||||
git add src/core/auth/
|
||||
git add test/business/auth/
|
||||
git add test/core/auth/
|
||||
git add docs/auth/
|
||||
|
||||
# ❌ 禁止使用交互式添加(容易误选范围外文件)
|
||||
# git add -i # 不推荐,风险太高
|
||||
```
|
||||
|
||||
### 2. 分阶段提交(推荐)
|
||||
将不同类型的修改分别提交,保持提交历史清晰:
|
||||
|
||||
```bash
|
||||
# 第一步:提交代码规范优化(仅限检查范围内)
|
||||
git add src/business/auth/ src/core/auth/
|
||||
git commit -m "style(auth):优化auth模块代码规范
|
||||
|
||||
范围:src/business/auth/, src/core/auth/
|
||||
- 统一命名规范和注释格式
|
||||
- 清理未使用的导入
|
||||
- 调整代码结构和缩进"
|
||||
|
||||
# 第二步:提交功能改进(如果有,仅限范围内)
|
||||
git add src/business/auth/enhanced-features/
|
||||
git commit -m "feat(auth):添加用户状态管理功能
|
||||
|
||||
范围:src/business/auth/
|
||||
- 实现用户激活/禁用功能
|
||||
- 添加状态变更日志记录
|
||||
- 支持批量状态操作"
|
||||
|
||||
# 第三步:提交测试相关(仅限范围内)
|
||||
git add test/business/auth/ test/core/auth/
|
||||
git commit -m "test(auth):完善auth模块测试覆盖
|
||||
|
||||
范围:test/business/auth/, test/core/auth/
|
||||
- 添加缺失的单元测试
|
||||
- 补充集成测试用例
|
||||
- 提升测试覆盖率到95%以上"
|
||||
|
||||
# 第四步:提交文档更新(仅限范围内)
|
||||
git add docs/auth/ src/business/auth/README.md src/core/auth/README.md
|
||||
git commit -m "docs(auth):更新auth模块文档
|
||||
|
||||
范围:docs/auth/, auth模块README文件
|
||||
- 完善API接口文档
|
||||
- 更新功能模块README
|
||||
- 添加使用示例和注意事项"
|
||||
```
|
||||
|
||||
### 3. 使用交互式暂存(精确控制)
|
||||
```bash
|
||||
# 交互式选择要提交的代码块(仅限范围内文件)
|
||||
git add -p src/business/auth/login.service.ts
|
||||
|
||||
# 选择代码规范相关的修改
|
||||
# 提交第一部分
|
||||
git commit -m "style(auth):优化login.service代码规范"
|
||||
|
||||
# 暂存剩余的功能修改
|
||||
git add src/business/auth/login.service.ts
|
||||
git commit -m "feat(auth):添加多因素认证支持"
|
||||
```
|
||||
|
||||
### 4. 范围外文件处理
|
||||
🚨 **重要:绝对不能处理范围外的文件!**
|
||||
|
||||
```bash
|
||||
# ✅ 正确做法:查看范围外的文件,但不做任何处理
|
||||
git status | findstr /v "auth" # 假设检查范围是auth模块,查看非auth文件
|
||||
|
||||
# ✅ 正确做法:只添加范围内的文件
|
||||
git add src/business/auth/
|
||||
git add src/core/auth/
|
||||
git add test/business/auth/
|
||||
|
||||
# ❌ 错误做法:不要重置、暂存或移动范围外文件
|
||||
# git checkout -- src/business/zulip/some-file.ts # 禁止!
|
||||
# git stash push src/business/zulip/ # 禁止!会影响其他AI
|
||||
# git reset HEAD src/business/user-mgmt/ # 禁止!会影响其他AI
|
||||
|
||||
# 🔥 协作原则:范围外文件必须保持原状,供其他AI处理
|
||||
```
|
||||
|
||||
### 5. 提交前最终检查
|
||||
```bash
|
||||
# 检查暂存区内容(确保只有范围内文件)
|
||||
git diff --cached --name-only
|
||||
|
||||
# 确认所有文件都在检查范围内
|
||||
git diff --cached --name-only | grep -E "^(src|test|docs)/(business|core)/auth/"
|
||||
|
||||
# 确认提交信息准确性
|
||||
git commit --dry-run
|
||||
|
||||
# 执行提交
|
||||
git commit -m "提交信息"
|
||||
```
|
||||
|
||||
## 📄 合并文档生成
|
||||
|
||||
### 🔥 重要规范:独立合并文档生成
|
||||
**在完成代码提交后,必须在docs目录中生成一个独立的合并md文档,方便最后统一完成合并操作。**
|
||||
|
||||
#### 合并文档命名规范
|
||||
```
|
||||
docs/merge-requests/[模块名称]-code-standard-[日期].md
|
||||
```
|
||||
|
||||
#### 合并文档存放位置
|
||||
- **目录路径**:`docs/merge-requests/`
|
||||
- **文件命名**:`[模块名称]-code-standard-[日期].md`
|
||||
- **示例文件名**:
|
||||
- `auth-code-standard-20240112.md`
|
||||
- `zulip-code-standard-20240112.md`
|
||||
- `user-mgmt-code-standard-20240112.md`
|
||||
|
||||
#### 创建合并文档目录
|
||||
如果`docs/merge-requests/`目录不存在,需要先创建:
|
||||
```bash
|
||||
mkdir -p docs/merge-requests
|
||||
```
|
||||
|
||||
### 合并请求文档模板
|
||||
完成所有提交后,在`docs/merge-requests/`目录中生成独立的合并文档:
|
||||
|
||||
```markdown
|
||||
# 代码规范优化合并请求
|
||||
|
||||
## 📋 变更概述
|
||||
本次合并请求包含对 [具体模块/功能] 的代码规范优化和质量提升。
|
||||
|
||||
## 🔍 主要变更内容
|
||||
|
||||
### 代码规范优化
|
||||
- **命名规范**:调整文件、类、方法命名符合项目规范
|
||||
- **注释规范**:完善注释内容,统一注释格式
|
||||
- **代码清理**:移除未使用的导入、变量和死代码
|
||||
- **格式统一**:统一代码缩进、换行和空格使用
|
||||
|
||||
### 功能改进(如适用)
|
||||
- **新增功能**:[具体描述新增的功能]
|
||||
- **Bug修复**:[具体描述修复的问题]
|
||||
- **性能优化**:[具体描述优化的内容]
|
||||
|
||||
### 测试完善(如适用)
|
||||
- **测试覆盖**:补充缺失的单元测试和集成测试
|
||||
- **测试质量**:提升测试用例的完整性和准确性
|
||||
|
||||
### 文档更新(如适用)
|
||||
- **API文档**:更新接口文档和使用说明
|
||||
- **README文档**:完善功能模块说明和使用指南
|
||||
|
||||
## 📊 影响范围
|
||||
- **修改文件数量**:[数量] 个文件
|
||||
- **新增代码行数**:+[数量] 行
|
||||
- **删除代码行数**:-[数量] 行
|
||||
- **测试覆盖率**:从 [原覆盖率]% 提升到 [新覆盖率]%
|
||||
|
||||
## 🧪 测试验证
|
||||
- [ ] 所有单元测试通过
|
||||
- [ ] 集成测试通过
|
||||
- [ ] E2E测试通过
|
||||
- [ ] 性能测试通过(如适用)
|
||||
- [ ] 手动功能验证通过
|
||||
|
||||
## 🔗 相关链接
|
||||
- 相关Issue:#[Issue编号]
|
||||
- 设计文档:[链接]
|
||||
- API文档:[链接]
|
||||
|
||||
## 📝 审查要点
|
||||
请重点关注以下方面:
|
||||
1. **代码规范**:命名、注释、格式是否符合项目标准
|
||||
2. **功能正确性**:新增或修改的功能是否按预期工作
|
||||
3. **测试完整性**:测试用例是否充分覆盖变更内容
|
||||
4. **文档准确性**:文档是否与代码实现保持一致
|
||||
5. **性能影响**:变更是否对系统性能产生负面影响
|
||||
|
||||
## ⚠️ 注意事项
|
||||
- 本次变更主要为代码质量提升,不涉及业务逻辑重大变更
|
||||
- 所有修改都经过充分测试验证
|
||||
- 建议在非高峰期进行合并部署
|
||||
|
||||
## 🚀 部署说明
|
||||
- **部署环境**:[测试环境/生产环境]
|
||||
- **部署时间**:[建议的部署时间]
|
||||
- **回滚方案**:如有问题可快速回滚到上一版本
|
||||
- **监控要点**:关注 [具体的监控指标]
|
||||
```
|
||||
|
||||
### 🚨 合并文档不纳入Git提交
|
||||
**重要:合并文档仅用于本地记录和合并操作参考,不应加入到Git提交中!**
|
||||
|
||||
#### 原因说明
|
||||
- 合并文档是临时性的操作记录,不属于项目代码的一部分
|
||||
- 避免在代码仓库中产生大量临时文档
|
||||
- 合并完成后,相关信息已体现在Git提交历史和PR记录中
|
||||
|
||||
#### 操作规范
|
||||
```bash
|
||||
# ❌ 禁止将合并文档加入Git提交
|
||||
git add docs/merge-requests/ # 禁止!
|
||||
|
||||
# ✅ 正确做法:确保合并文档不被提交
|
||||
# 方法1:在.gitignore中已配置忽略(推荐)
|
||||
# 方法2:提交时明确排除
|
||||
git add . -- ':!docs/merge-requests/'
|
||||
|
||||
# ✅ 检查暂存区,确认没有合并文档
|
||||
git diff --cached --name-only | grep "merge-requests"
|
||||
# 如果有输出,需要取消暂存
|
||||
git reset HEAD docs/merge-requests/
|
||||
```
|
||||
|
||||
#### .gitignore 配置建议
|
||||
确保项目的 `.gitignore` 文件中包含:
|
||||
```
|
||||
# 合并文档目录(不纳入版本控制)
|
||||
docs/merge-requests/
|
||||
```
|
||||
|
||||
### 📝 独立合并文档创建示例
|
||||
|
||||
#### 1. 创建合并文档目录(如果不存在)
|
||||
```bash
|
||||
mkdir -p docs/merge-requests
|
||||
```
|
||||
|
||||
#### 2. 生成具体的合并文档
|
||||
假设当前检查的是auth模块,日期是2024-01-12,则创建文件:
|
||||
`docs/merge-requests/auth-code-standard-20240112.md`
|
||||
|
||||
#### 3. 合并文档内容示例
|
||||
```markdown
|
||||
# Auth模块代码规范优化合并请求
|
||||
|
||||
## 📋 变更概述
|
||||
本次合并请求包含对Auth模块的代码规范优化和质量提升,涉及登录、注册、权限验证等核心功能。
|
||||
|
||||
## 🔍 主要变更内容
|
||||
|
||||
### 代码规范优化
|
||||
- **命名规范**:统一service、controller、entity文件命名
|
||||
- **注释规范**:完善JSDoc注释,添加参数和返回值说明
|
||||
- **代码清理**:移除未使用的导入和死代码
|
||||
- **格式统一**:统一TypeScript代码缩进和换行
|
||||
|
||||
### 功能改进
|
||||
- **错误处理**:完善异常捕获和错误提示
|
||||
- **类型安全**:添加缺失的TypeScript类型定义
|
||||
- **性能优化**:优化数据库查询和缓存策略
|
||||
|
||||
### 测试完善
|
||||
- **测试覆盖**:补充登录服务和注册控制器的单元测试
|
||||
- **集成测试**:添加JWT认证流程的集成测试
|
||||
- **E2E测试**:完善用户注册登录的端到端测试
|
||||
|
||||
## 📊 影响范围
|
||||
- **修改文件数量**:15个文件
|
||||
- **涉及模块**:src/business/auth/, src/core/auth/, test/business/auth/
|
||||
- **新增代码行数**:+245行
|
||||
- **删除代码行数**:-89行
|
||||
- **测试覆盖率**:从78%提升到95%
|
||||
|
||||
## 🧪 测试验证
|
||||
- [x] 所有单元测试通过 (npm run test:auth:unit)
|
||||
- [x] 集成测试通过 (npm run test:auth:integration)
|
||||
- [x] E2E测试通过 (npm run test:auth:e2e)
|
||||
- [x] 手动功能验证通过
|
||||
|
||||
## 🔗 相关信息
|
||||
- **分支名称**:feature/code-standard-auth-20240112
|
||||
- **远程仓库**:origin
|
||||
- **检查日期**:2024-01-12
|
||||
- **检查人员**:[用户名称]
|
||||
|
||||
## 📝 合并后操作
|
||||
1. 验证生产环境功能正常
|
||||
2. 监控登录注册成功率
|
||||
3. 关注系统性能指标
|
||||
4. 更新相关文档链接
|
||||
|
||||
---
|
||||
**文档生成时间**:2024-01-12
|
||||
**对应分支**:feature/code-standard-auth-20240112
|
||||
**合并状态**:待合并
|
||||
```
|
||||
|
||||
#### 4. 在PR中引用合并文档
|
||||
创建Pull Request时,在描述中添加:
|
||||
```markdown
|
||||
## 📄 详细合并文档
|
||||
请查看独立合并文档:`docs/merge-requests/auth-code-standard-20240112.md`
|
||||
|
||||
该文档包含完整的变更说明、测试验证结果和合并后操作指南。
|
||||
```
|
||||
|
||||
## 🔧 执行步骤总结
|
||||
|
||||
### 完整执行流程
|
||||
1. **Git变更检查**
|
||||
- 执行 `git status` 和 `git diff` 查看变更
|
||||
- 确认所有修改文件都在当前检查任务的范围内
|
||||
- 排除或暂存范围外的文件
|
||||
|
||||
2. **修改记录校验**
|
||||
- 逐个检查修改文件的头部注释
|
||||
- 确认修改记录与实际变更内容一致
|
||||
- 如有不一致,立即修正
|
||||
|
||||
3. **创建功能分支**
|
||||
- 🔥 **在当前分支基础上**创建新分支(不切换到主分支)
|
||||
- 根据修改类型和检查范围创建合适的分支
|
||||
- 使用规范的分支命名格式(包含模块标识)
|
||||
|
||||
4. **分类提交代码**
|
||||
- 按修改类型分别提交(style、feat、fix、docs等)
|
||||
- 使用规范的提交信息格式(包含范围标识)
|
||||
- 每次提交保持原子性(一次提交只做一件事)
|
||||
- 确保每次提交只包含检查范围内的文件
|
||||
|
||||
5. **推送到指定远程仓库**
|
||||
- 询问用户要推送到哪个远程仓库
|
||||
- 使用 `git push [远程仓库名] [分支名]` 推送到指定远程仓库
|
||||
- 验证推送结果和分支状态
|
||||
|
||||
6. **生成独立合并文档**
|
||||
- 在 `docs/merge-requests/` 目录中创建独立的合并md文档
|
||||
- 使用规范的文件命名:`[模块名称]-code-standard-[日期].md`
|
||||
- 包含完整的变更概述、影响范围、测试验证等信息
|
||||
- 方便后续统一进行合并操作管理
|
||||
|
||||
7. **创建PR和关联文档**
|
||||
- 在指定的远程仓库创建Pull Request
|
||||
- 在PR描述中引用独立合并文档的路径
|
||||
- 明确标注检查范围和变更内容
|
||||
|
||||
## 🚀 推送到远程仓库
|
||||
|
||||
### 📋 执行前询问
|
||||
**在推送前,AI必须询问用户以下信息:**
|
||||
1. **目标远程仓库名称**:要推送到哪个远程仓库?(如:origin、whale-town-end、upstream等)
|
||||
2. **确认分支名称**:确认要推送的分支名称是否正确
|
||||
|
||||
### 推送新分支到指定远程仓库
|
||||
完成所有提交后,将分支推送到用户指定的远程仓库:
|
||||
|
||||
```bash
|
||||
# 推送新分支到指定远程仓库([远程仓库名]由用户提供)
|
||||
git push [远程仓库名] feature/code-standard-[模块名称]-[日期]
|
||||
|
||||
# 示例:推送到origin远程仓库
|
||||
git push origin feature/code-standard-auth-20240112
|
||||
|
||||
# 示例:推送到whale-town-end远程仓库
|
||||
git push whale-town-end feature/code-standard-auth-20240112
|
||||
|
||||
# 示例:推送到upstream远程仓库
|
||||
git push upstream feature/code-standard-zulip-20240112
|
||||
|
||||
# 如果是首次推送该分支,设置上游跟踪
|
||||
git push -u [远程仓库名] feature/code-standard-auth-20240112
|
||||
```
|
||||
|
||||
### 验证推送结果
|
||||
```bash
|
||||
# 查看远程分支状态
|
||||
git branch -r
|
||||
|
||||
# 确认分支已成功推送到指定远程仓库
|
||||
git ls-remote [远程仓库名] | grep feature/code-standard-[模块名称]-[日期]
|
||||
|
||||
# 查看指定远程仓库的所有分支
|
||||
git ls-remote [远程仓库名]
|
||||
```
|
||||
|
||||
### 远程仓库配置检查
|
||||
如果推送时遇到问题,可以检查远程仓库配置:
|
||||
|
||||
```bash
|
||||
# 查看当前配置的所有远程仓库
|
||||
git remote -v
|
||||
|
||||
# 如果没有指定的远程仓库,需要添加
|
||||
git remote add [远程仓库名] [仓库URL]
|
||||
|
||||
# 验证指定远程仓库连接
|
||||
git remote show [远程仓库名]
|
||||
```
|
||||
|
||||
### 🔍 常见远程仓库名称
|
||||
- **origin**:通常是默认的远程仓库
|
||||
- **upstream**:通常指向原始项目仓库
|
||||
- **whale-town-end**:项目特定的远程仓库名
|
||||
- **fork**:个人fork的仓库
|
||||
- **dev**:开发环境仓库
|
||||
|
||||
## ⚠️ 重要注意事项
|
||||
|
||||
### 提交原则
|
||||
- **范围限制**:只提交当前检查任务范围内的文件,不涉及其他模块
|
||||
- **原子性**:每次提交只包含一个逻辑改动
|
||||
- **完整性**:每次提交的代码都应该能正常运行
|
||||
- **描述性**:提交信息要清晰描述改动内容、范围和原因
|
||||
- **一致性**:文件修改记录必须与实际修改内容一致
|
||||
- **合并文档排除**:`docs/merge-requests/` 目录下的合并文档不纳入Git提交
|
||||
|
||||
### 质量保证
|
||||
- 提交前必须验证代码能正常运行
|
||||
- 确保所有测试通过
|
||||
- 检查代码格式和规范符合项目标准
|
||||
- 验证文档与代码实现保持一致
|
||||
|
||||
### 协作规范
|
||||
- 遵循项目的分支管理策略
|
||||
- 推送前询问并确认目标远程仓库
|
||||
- 提供清晰的合并请求说明
|
||||
- 及时响应代码审查意见
|
||||
- 保持提交历史的清晰和可追溯性
|
||||
|
||||
## 🔥 重要提醒
|
||||
|
||||
**如果在本步骤中执行了任何修改操作(修正文件头部信息、调整提交内容、更新文档等),必须立即重新执行步骤7的完整检查!**
|
||||
|
||||
- ✅ 执行修改 → 🔥 立即重新执行步骤7 → 提供验证报告 → 等待用户确认
|
||||
- ❌ 执行修改 → 直接结束检查(错误做法)
|
||||
|
||||
**🚨 重要强调:纯检查步骤不更新修改记录**
|
||||
**如果检查发现代码提交已经符合规范,无需任何修改,则:**
|
||||
- ❌ **禁止添加检查记录**:不要添加"AI代码检查步骤7:代码提交检查和优化"
|
||||
- ❌ **禁止更新时间戳**:不要修改@lastModified字段
|
||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||
|
||||
**不能跳过重新检查环节!**
|
||||
|
||||
### 🔥 合并文档生成强制要求
|
||||
**每次完成代码提交后,必须在docs/merge-requests/目录中生成独立的合并md文档!**
|
||||
|
||||
- ✅ 完成提交 → 生成独立合并文档 → 在PR中引用文档路径
|
||||
- ❌ 完成提交 → 直接创建PR(缺少独立文档)
|
||||
|
||||
**独立合并文档是统一管理合并操作的重要依据,不能省略!**
|
||||
|
||||
## 📋 执行前必须询问的信息
|
||||
|
||||
**在执行推送操作前,AI必须询问用户:**
|
||||
|
||||
1. **目标远程仓库名称**
|
||||
- 问题:请问要推送到哪个远程仓库?
|
||||
- 示例回答:origin / whale-town-end / upstream / 其他
|
||||
|
||||
2. **确认分支名称**
|
||||
- 问题:确认要推送的分支名称是:feature/code-standard-[模块名称]-[日期] 吗?
|
||||
- 等待用户确认或提供正确的分支名称
|
||||
|
||||
**只有获得用户明确回答后,才能执行推送操作!**
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* AI代码检查用户信息管理脚本
|
||||
*
|
||||
* 功能:获取当前日期和用户名称,保存到me.config.json供AI检查步骤使用
|
||||
*
|
||||
* @author AI助手
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-13
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const configPath = path.join(__dirname, '..', 'me.config.json');
|
||||
|
||||
// 获取当前日期(YYYY-MM-DD格式)
|
||||
function getCurrentDate() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 读取现有配置
|
||||
function readConfig() {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error('❌ 读取配置文件失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
function saveConfig(config) {
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
console.log('✅ 配置已保存');
|
||||
} catch (error) {
|
||||
console.error('❌ 保存配置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 提示用户输入名称
|
||||
function promptUserName() {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question('👤 请输入您的名称或昵称: ', (name) => {
|
||||
rl.close();
|
||||
resolve(name.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 主执行逻辑
|
||||
async function main() {
|
||||
console.log('🚀 AI代码检查 - 用户信息设置');
|
||||
|
||||
const currentDate = getCurrentDate();
|
||||
console.log('📅 当前日期:', currentDate);
|
||||
|
||||
const existingConfig = readConfig();
|
||||
|
||||
// 如果配置存在且日期匹配,直接返回
|
||||
if (existingConfig && existingConfig.date === currentDate) {
|
||||
console.log('✅ 配置已是最新,当前用户:', existingConfig.name);
|
||||
return existingConfig;
|
||||
}
|
||||
|
||||
// 需要更新配置
|
||||
console.log('🔄 需要更新用户信息...');
|
||||
const userName = await promptUserName();
|
||||
|
||||
if (!userName) {
|
||||
console.error('❌ 用户名称不能为空');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = {
|
||||
date: currentDate,
|
||||
name: userName
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
console.log('🎉 设置完成!', config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// 导出函数供其他脚本使用
|
||||
function getConfig() {
|
||||
return readConfig();
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('❌ 脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getConfig, getCurrentDate };
|
||||
@@ -1,220 +1,45 @@
|
||||
# 后端开发规范指南
|
||||
|
||||
本文档定义了基于四层架构的后端开发规范,包括架构规范、注释规范、日志规范、代码质量规范等。
|
||||
本文档定义了后端开发的核心规范,包括注释规范、日志规范、业务逻辑规范等,确保代码质量和团队协作效率。
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [架构规范](#架构规范)
|
||||
- [注释规范](#注释规范)
|
||||
- [日志规范](#日志规范)
|
||||
- [业务逻辑规范](#业务逻辑规范)
|
||||
- [异常处理规范](#异常处理规范)
|
||||
- [代码质量规范](#代码质量规范)
|
||||
- [最佳实践](#最佳实践)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构规范
|
||||
|
||||
### 四层架构原则
|
||||
|
||||
项目采用 **Gateway-Business-Core-Data** 四层架构,每层职责明确:
|
||||
|
||||
```
|
||||
Gateway Layer (网关层)
|
||||
↓ 依赖
|
||||
Business Layer (业务层)
|
||||
↓ 依赖
|
||||
Core Layer (核心层)
|
||||
↓ 依赖
|
||||
Data Layer (数据层)
|
||||
```
|
||||
|
||||
### 各层职责
|
||||
|
||||
#### 🌐 Gateway Layer(网关层)
|
||||
|
||||
**位置:** `src/gateway/`
|
||||
|
||||
**职责:**
|
||||
- HTTP/WebSocket协议处理
|
||||
- 请求参数验证(DTO)
|
||||
- 路由管理
|
||||
- 认证守卫
|
||||
- 错误转换
|
||||
|
||||
**规范:**
|
||||
```typescript
|
||||
// ✅ 正确:只做协议转换
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
constructor(private readonly loginService: LoginService) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() dto: LoginDto, @Res() res: Response) {
|
||||
const result = await this.loginService.login(dto);
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:包含业务逻辑
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
@Post('login')
|
||||
async login(@Body() dto: LoginDto) {
|
||||
const user = await this.usersService.findByEmail(dto.email);
|
||||
const isValid = await bcrypt.compare(dto.password, user.password);
|
||||
// ... 更多业务逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 🎯 Business Layer(业务层)
|
||||
|
||||
**位置:** `src/business/`
|
||||
|
||||
**职责:**
|
||||
- 业务逻辑实现
|
||||
- 服务协调
|
||||
- 业务规则验证
|
||||
- 事务管理
|
||||
|
||||
**规范:**
|
||||
```typescript
|
||||
// ✅ 正确:实现业务逻辑
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
// 1. 调用核心服务验证
|
||||
const user = await this.loginCoreService.validateUser(dto);
|
||||
|
||||
// 2. 业务逻辑:生成Token
|
||||
const tokens = await this.loginCoreService.generateTokens(user);
|
||||
|
||||
// 3. 业务逻辑:发送登录通知
|
||||
await this.emailService.sendLoginNotification(user.email);
|
||||
|
||||
return { success: true, data: tokens };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:直接访问数据库
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.userRepository.findOne({ email: dto.email });
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ⚙️ Core Layer(核心层)
|
||||
|
||||
**位置:** `src/core/`
|
||||
|
||||
**职责:**
|
||||
- 数据访问
|
||||
- 基础设施
|
||||
- 外部系统集成
|
||||
- 工具服务
|
||||
|
||||
**规范:**
|
||||
```typescript
|
||||
// ✅ 正确:提供技术基础设施
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
constructor(
|
||||
@Inject('IUsersService')
|
||||
private readonly usersService: IUsersService,
|
||||
@Inject('IRedisService')
|
||||
private readonly redisService: IRedisService,
|
||||
) {}
|
||||
|
||||
async validateUser(dto: LoginDto): Promise<User> {
|
||||
const user = await this.usersService.findByEmail(dto.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(dto.password, user.password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('密码错误');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:包含业务逻辑
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
async validateUser(dto: LoginDto) {
|
||||
// 发送邮件通知 - 这是业务逻辑,应该在Business层
|
||||
await this.emailService.sendLoginNotification(user.email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模块组织规范
|
||||
|
||||
```typescript
|
||||
// 模块命名:功能名.module.ts
|
||||
// 服务命名:功能名.service.ts
|
||||
// 控制器命名:功能名.controller.ts
|
||||
// 网关命名:功能名.gateway.ts
|
||||
|
||||
// ✅ 正确的模块结构
|
||||
src/
|
||||
├── gateway/
|
||||
│ └── auth/
|
||||
│ ├── login.controller.ts
|
||||
│ ├── register.controller.ts
|
||||
│ ├── jwt_auth.guard.ts
|
||||
│ ├── dto/
|
||||
│ └── auth.gateway.module.ts
|
||||
├── business/
|
||||
│ └── auth/
|
||||
│ ├── login.service.ts
|
||||
│ ├── register.service.ts
|
||||
│ └── auth.module.ts
|
||||
└── core/
|
||||
└── login_core/
|
||||
├── login_core.service.ts
|
||||
└── login_core.module.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## <20> 注释规规范
|
||||
## 📝 注释规范
|
||||
|
||||
### 文件头注释
|
||||
|
||||
每个 TypeScript 文件都必须包含完整的文件头注释:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 用户登录服务
|
||||
* 文件功能描述
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理用户登录业务逻辑
|
||||
* - 协调登录核心服务和邮件服务
|
||||
* - 生成JWT令牌
|
||||
* - 主要功能点1
|
||||
* - 主要功能点2
|
||||
* - 主要功能点3
|
||||
*
|
||||
* 架构层级:Business Layer
|
||||
* 职责分离:
|
||||
* - 职责描述1
|
||||
* - 职责描述2
|
||||
*
|
||||
* 依赖服务:
|
||||
* - LoginCoreService: 登录核心逻辑
|
||||
* - EmailService: 邮件发送服务
|
||||
* 最近修改:
|
||||
* - YYYY-MM-DD: 修改类型 - 具体修改内容描述
|
||||
* - YYYY-MM-DD: 修改类型 - 具体修改内容描述
|
||||
*
|
||||
* @author 作者名
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-01
|
||||
* @version x.x.x
|
||||
* @since 创建日期
|
||||
* @lastModified 最后修改日期
|
||||
*/
|
||||
```
|
||||
|
||||
@@ -222,75 +47,149 @@ src/
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 登录业务服务
|
||||
* 类功能描述
|
||||
*
|
||||
* 职责:
|
||||
* - 实现用户登录业务逻辑
|
||||
* - 协调核心服务完成登录流程
|
||||
* - 处理登录相关的业务规则
|
||||
* - 主要职责1
|
||||
* - 主要职责2
|
||||
*
|
||||
* 主要方法:
|
||||
* - login() - 用户登录
|
||||
* - verificationCodeLogin() - 验证码登录
|
||||
* - refreshToken() - 刷新令牌
|
||||
* - method1() - 方法1功能
|
||||
* - method2() - 方法2功能
|
||||
*
|
||||
* 使用场景:
|
||||
* - 场景描述
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
// 实现
|
||||
export class ExampleService {
|
||||
// 类实现
|
||||
}
|
||||
```
|
||||
|
||||
### 方法注释(三级标准)
|
||||
### 方法注释(三级注释标准)
|
||||
|
||||
**必须包含以下三个级别的注释:**
|
||||
|
||||
#### 1. 功能描述级别
|
||||
```typescript
|
||||
/**
|
||||
* 用户登录
|
||||
* 用户登录验证
|
||||
*/
|
||||
```
|
||||
|
||||
#### 2. 业务逻辑级别
|
||||
```typescript
|
||||
/**
|
||||
* 用户登录验证
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 调用核心服务验证用户凭证
|
||||
* 2. 生成访问令牌和刷新令牌
|
||||
* 3. 发送登录成功通知邮件
|
||||
* 4. 记录登录日志
|
||||
* 5. 返回登录结果
|
||||
* 1. 验证用户名或邮箱格式
|
||||
* 2. 查找用户记录
|
||||
* 3. 验证密码哈希值
|
||||
* 4. 检查用户状态是否允许登录
|
||||
* 5. 记录登录日志
|
||||
* 6. 返回认证结果
|
||||
*/
|
||||
```
|
||||
|
||||
#### 3. 技术实现级别
|
||||
```typescript
|
||||
/**
|
||||
* 用户登录验证
|
||||
*
|
||||
* @param dto 登录请求数据
|
||||
* @returns 登录结果,包含用户信息和令牌
|
||||
* @throws UnauthorizedException 用户名或密码错误
|
||||
* @throws ForbiddenException 用户状态不允许登录
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户名或邮箱格式
|
||||
* 2. 查找用户记录
|
||||
* 3. 验证密码哈希值
|
||||
* 4. 检查用户状态是否允许登录
|
||||
* 5. 记录登录日志
|
||||
* 6. 返回认证结果
|
||||
*
|
||||
* @param loginRequest 登录请求数据
|
||||
* @returns 认证结果,包含用户信息和认证状态
|
||||
* @throws UnauthorizedException 用户名或密码错误时
|
||||
* @throws ForbiddenException 用户状态不允许登录时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await loginService.login({
|
||||
* const result = await loginService.validateUser({
|
||||
* identifier: 'user@example.com',
|
||||
* password: 'password123'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
// 实现
|
||||
async validateUser(loginRequest: LoginRequest): Promise<AuthResult> {
|
||||
// 实现代码
|
||||
}
|
||||
```
|
||||
|
||||
### 修改记录规范
|
||||
|
||||
#### 修改类型定义
|
||||
|
||||
- **代码规范优化** - 命名规范、注释规范、代码清理等
|
||||
- **功能新增** - 添加新的功能或方法
|
||||
- **功能修改** - 修改现有功能的实现
|
||||
- **Bug修复** - 修复代码缺陷
|
||||
- **性能优化** - 提升代码性能
|
||||
- **重构** - 代码结构调整但功能不变
|
||||
|
||||
#### 修改记录格式
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 最近修改:
|
||||
* - 2025-01-15: 架构重构 - 迁移到四层架构,分离网关层和业务层
|
||||
* - 2025-01-10: 功能新增 - 添加验证码登录功能
|
||||
* - 2025-01-08: Bug修复 - 修复Token刷新逻辑错误
|
||||
* - 2025-01-05: 代码规范优化 - 统一异常处理格式
|
||||
* - 2025-01-03: 性能优化 - 优化数据库查询性能
|
||||
* - 2025-01-07: 代码规范优化 - 清理未使用的导入(EmailSendResult, crypto)
|
||||
* - 2025-01-07: 代码规范优化 - 修复常量命名(saltRounds -> SALT_ROUNDS)
|
||||
* - 2025-01-07: 功能新增 - 添加用户验证码登录功能
|
||||
* - 2025-01-06: Bug修复 - 修复邮箱验证逻辑错误
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @lastModified 2025-01-15
|
||||
* @version 1.0.1 (修改后需要递增版本号)
|
||||
* @lastModified 2025-01-07
|
||||
*/
|
||||
```
|
||||
|
||||
**修改记录原则:**
|
||||
- 只保留最近5次修改
|
||||
- 包含日期、类型、描述
|
||||
- 重大版本更新标注版本号
|
||||
#### 修改记录长度限制
|
||||
|
||||
**重要:为保持文件头注释简洁,修改记录只保留最近的5次修改。**
|
||||
|
||||
- ✅ **保留最新5条记录** - 便于快速了解最近变更
|
||||
- ✅ **超出时删除最旧记录** - 保持注释简洁
|
||||
- ✅ **重要修改可标注** - 重大版本更新可特别标注
|
||||
|
||||
```typescript
|
||||
// ✅ 正确示例:保持最新5条记录
|
||||
/**
|
||||
* 最近修改:
|
||||
* - 2025-01-07: 功能新增 - 添加用户头像上传功能
|
||||
* - 2025-01-06: Bug修复 - 修复邮箱验证逻辑错误
|
||||
* - 2025-01-05: 代码规范优化 - 统一异常处理格式
|
||||
* - 2025-01-04: 功能修改 - 优化用户状态管理逻辑
|
||||
* - 2025-01-03: 性能优化 - 优化数据库查询性能
|
||||
*
|
||||
* @version 1.3.0
|
||||
*/
|
||||
|
||||
// ❌ 错误示例:记录过多,注释冗长
|
||||
/**
|
||||
* 最近修改:
|
||||
* - 2025-01-07: 功能新增 - 添加用户头像上传功能
|
||||
* - 2025-01-06: Bug修复 - 修复邮箱验证逻辑错误
|
||||
* - 2025-01-05: 代码规范优化 - 统一异常处理格式
|
||||
* - 2025-01-04: 功能修改 - 优化用户状态管理逻辑
|
||||
* - 2025-01-03: 性能优化 - 优化数据库查询性能
|
||||
* - 2025-01-02: 重构 - 重构用户认证逻辑
|
||||
* - 2025-01-01: 功能新增 - 添加用户权限管理
|
||||
* - 2024-12-31: Bug修复 - 修复登录超时问题
|
||||
* // ... 更多记录导致注释过长
|
||||
*/
|
||||
```
|
||||
|
||||
#### 版本号递增规则
|
||||
|
||||
- **代码规范优化、Bug修复** → 修订版本 +1 (1.0.0 → 1.0.1)
|
||||
- **功能新增、功能修改** → 次版本 +1 (1.0.1 → 1.1.0)
|
||||
- **重构、架构变更** → 主版本 +1 (1.1.0 → 2.0.0)
|
||||
|
||||
---
|
||||
|
||||
@@ -300,37 +199,22 @@ async login(dto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
|
||||
```typescript
|
||||
// ERROR - 系统错误,需要立即处理
|
||||
this.logger.error('用户登录失败', {
|
||||
userId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
this.logger.error('用户登录失败', { userId, error: error.message });
|
||||
|
||||
// WARN - 警告信息,需要关注
|
||||
this.logger.warn('用户多次登录失败', {
|
||||
userId,
|
||||
attemptCount,
|
||||
ip: request.ip
|
||||
});
|
||||
// WARN - 警告信息,需要关注但不影响系统运行
|
||||
this.logger.warn('用户多次登录失败', { userId, attemptCount });
|
||||
|
||||
// INFO - 重要的业务操作
|
||||
this.logger.info('用户登录成功', {
|
||||
userId,
|
||||
loginTime: new Date(),
|
||||
ip: request.ip
|
||||
});
|
||||
// INFO - 重要的业务操作记录
|
||||
this.logger.info('用户登录成功', { userId, loginTime: new Date() });
|
||||
|
||||
// DEBUG - 调试信息(仅开发环境)
|
||||
this.logger.debug('验证用户密码', {
|
||||
userId,
|
||||
passwordHash: '***'
|
||||
});
|
||||
// DEBUG - 调试信息,仅在开发环境使用
|
||||
this.logger.debug('验证用户密码', { userId, hashedPassword: '***' });
|
||||
```
|
||||
|
||||
### 日志格式规范
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:结构化日志
|
||||
// ✅ 正确格式
|
||||
this.logger.info('操作描述', {
|
||||
userId: 'user123',
|
||||
action: 'login',
|
||||
@@ -338,26 +222,68 @@ this.logger.info('操作描述', {
|
||||
metadata: { ip: '192.168.1.1' }
|
||||
});
|
||||
|
||||
// ❌ 错误:字符串拼接
|
||||
// ❌ 错误格式
|
||||
this.logger.info('用户登录');
|
||||
this.logger.info(`用户${userId}登录成功`);
|
||||
```
|
||||
|
||||
### 敏感信息处理
|
||||
---
|
||||
|
||||
## 🏗️ 业务逻辑规范
|
||||
|
||||
### 防御性编程
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:隐藏敏感信息
|
||||
this.logger.info('用户注册', {
|
||||
email: user.email,
|
||||
password: '***', // 密码不记录
|
||||
apiKey: '***' // API密钥不记录
|
||||
});
|
||||
async getUserById(userId: string): Promise<User> {
|
||||
// 1. 参数验证
|
||||
if (!userId) {
|
||||
throw new BadRequestException('用户ID不能为空');
|
||||
}
|
||||
|
||||
// ❌ 错误:暴露敏感信息
|
||||
this.logger.info('用户注册', {
|
||||
email: user.email,
|
||||
password: user.password, // 危险!
|
||||
apiKey: user.apiKey // 危险!
|
||||
});
|
||||
// 2. 业务逻辑验证
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 3. 状态检查
|
||||
if (user.status === UserStatus.DELETED) {
|
||||
throw new ForbiddenException('用户已被删除');
|
||||
}
|
||||
|
||||
// 4. 返回结果
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### 业务逻辑分层
|
||||
|
||||
```typescript
|
||||
// Controller 层 - 只处理HTTP请求和响应
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return this.usersService.getUserById(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Service 层 - 处理业务逻辑
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async getUserById(id: string): Promise<User> {
|
||||
// 业务逻辑实现
|
||||
return this.usersCoreService.findUserById(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Core 层 - 核心业务实现
|
||||
@Injectable()
|
||||
export class UsersCoreService {
|
||||
async findUserById(id: string): Promise<User> {
|
||||
// 核心逻辑实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -386,66 +312,38 @@ throw new ConflictException('用户名已存在');
|
||||
throw new InternalServerErrorException('系统内部错误');
|
||||
```
|
||||
|
||||
### 分层异常处理
|
||||
### 异常处理模式
|
||||
|
||||
```typescript
|
||||
// Gateway Layer - 转换为HTTP响应
|
||||
@Controller('auth')
|
||||
export class LoginController {
|
||||
@Post('login')
|
||||
async login(@Body() dto: LoginDto, @Res() res: Response) {
|
||||
const result = await this.loginService.login(dto);
|
||||
async createUser(userData: CreateUserDto): Promise<User> {
|
||||
try {
|
||||
// 1. 参数验证
|
||||
this.validateUserData(userData);
|
||||
|
||||
if (result.success) {
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
const statusCode = this.getErrorStatusCode(result);
|
||||
res.status(statusCode).json(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Business Layer - 返回业务响应
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
async login(dto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
const user = await this.loginCoreService.validateUser(dto);
|
||||
const tokens = await this.loginCoreService.generateTokens(user);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tokens,
|
||||
message: '登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('登录失败', { dto, error: error.message });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
error_code: 'LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Core Layer - 抛出技术异常
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
async validateUser(dto: LoginDto): Promise<User> {
|
||||
const user = await this.usersService.findByEmail(dto.email);
|
||||
// 2. 业务逻辑检查
|
||||
await this.checkUserExists(userData.email);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
// 3. 执行创建操作
|
||||
const user = await this.usersRepository.create(userData);
|
||||
|
||||
const isValid = await bcrypt.compare(dto.password, user.password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('密码错误');
|
||||
}
|
||||
// 4. 记录成功日志
|
||||
this.logger.info('用户创建成功', { userId: user.id });
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
// 5. 记录错误日志
|
||||
this.logger.error('用户创建失败', {
|
||||
userData: { ...userData, password: '***' },
|
||||
error: error.message
|
||||
});
|
||||
|
||||
// 6. 重新抛出业务异常
|
||||
if (error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 7. 转换为系统异常
|
||||
throw new InternalServerErrorException('用户创建失败');
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -456,233 +354,152 @@ export class LoginCoreService {
|
||||
|
||||
### 代码检查清单
|
||||
|
||||
提交代码前确保:
|
||||
|
||||
- [ ] **架构规范**
|
||||
- [ ] 代码放在正确的架构层
|
||||
- [ ] 没有跨层直接调用(如Gateway直接调用Core)
|
||||
- [ ] 依赖方向正确(上层依赖下层)
|
||||
- [ ] 模块职责单一明确
|
||||
在提交代码前,请确保:
|
||||
|
||||
- [ ] **注释完整性**
|
||||
- [ ] 文件头注释包含架构层级说明
|
||||
- [ ] 类注释说明职责和主要方法
|
||||
- [ ] 方法注释包含业务逻辑和技术实现
|
||||
- [ ] 修改记录保持最近5次
|
||||
- [ ] 文件头注释包含功能描述、修改记录、作者信息
|
||||
- [ ] 类注释包含职责、主要方法、使用场景
|
||||
- [ ] 方法注释包含三级注释(功能、业务逻辑、技术实现)
|
||||
- [ ] 修改现有文件时添加了修改记录和更新版本号
|
||||
- [ ] 修改记录只保留最近5次,保持注释简洁
|
||||
|
||||
- [ ] **业务逻辑完整性**
|
||||
- [ ] 所有参数都进行了验证
|
||||
- [ ] 所有异常情况都进行了处理
|
||||
- [ ] 关键操作都记录了日志
|
||||
- [ ] 业务逻辑考虑了所有边界情况
|
||||
|
||||
- [ ] **代码质量**
|
||||
- [ ] 没有未使用的导入和变量
|
||||
- [ ] 常量使用正确命名(UPPER_SNAKE_CASE)
|
||||
- [ ] 方法长度合理(不超过50行)
|
||||
- [ ] 单一职责原则
|
||||
- [ ] 常量使用了正确的命名规范
|
||||
- [ ] 方法长度合理(建议不超过50行)
|
||||
- [ ] 单一职责原则,每个方法只做一件事
|
||||
|
||||
- [ ] **日志规范**
|
||||
- [ ] 关键操作记录日志
|
||||
- [ ] 使用结构化日志格式
|
||||
- [ ] 敏感信息已隐藏
|
||||
- [ ] 日志级别使用正确
|
||||
|
||||
- [ ] **异常处理**
|
||||
- [ ] 所有异常情况都处理
|
||||
- [ ] 异常类型使用正确
|
||||
- [ ] 错误信息清晰明确
|
||||
- [ ] 记录了错误日志
|
||||
- [ ] **安全性**
|
||||
- [ ] 敏感信息不在日志中暴露
|
||||
- [ ] 用户输入都进行了验证和清理
|
||||
- [ ] 权限检查在适当的位置进行
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 遵循四层架构
|
||||
### 1. 注释驱动开发
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:清晰的层次调用
|
||||
// Gateway → Business → Core → Data
|
||||
|
||||
// Gateway Layer
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get(':id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return this.usersService.getUserById(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Business Layer
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly usersCoreService: UsersCoreService) {}
|
||||
|
||||
async getUserById(id: string): Promise<ApiResponse<User>> {
|
||||
try {
|
||||
const user = await this.usersCoreService.findUserById(id);
|
||||
return { success: true, data: user };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Core Layer
|
||||
@Injectable()
|
||||
export class UsersCoreService {
|
||||
constructor(
|
||||
@Inject('IUsersService')
|
||||
private readonly usersDataService: IUsersService
|
||||
) {}
|
||||
|
||||
async findUserById(id: string): Promise<User> {
|
||||
const user = await this.usersDataService.findOne(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
/**
|
||||
* 用户注册功能
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证邮箱格式和唯一性
|
||||
* 2. 验证密码强度
|
||||
* 3. 生成邮箱验证码
|
||||
* 4. 创建用户记录
|
||||
* 5. 发送验证邮件
|
||||
* 6. 返回注册结果
|
||||
*
|
||||
* @param registerData 注册数据
|
||||
* @returns 注册结果
|
||||
*/
|
||||
async registerUser(registerData: RegisterDto): Promise<RegisterResult> {
|
||||
// 先写注释,再写实现
|
||||
// 这样确保逻辑清晰,不遗漏步骤
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 使用依赖注入接口
|
||||
### 2. 错误优先处理
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用接口依赖注入
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
constructor(
|
||||
@Inject('IUsersService')
|
||||
private readonly usersService: IUsersService,
|
||||
@Inject('IRedisService')
|
||||
private readonly redisService: IRedisService,
|
||||
) {}
|
||||
}
|
||||
|
||||
// ❌ 错误:直接依赖具体实现
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly redisService: RealRedisService,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 统一响应格式
|
||||
|
||||
```typescript
|
||||
// 定义统一的响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
// Business Layer 返回统一格式
|
||||
async login(dto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
const result = await this.loginCoreService.validateUser(dto);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: '登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
error_code: 'LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 防御性编程
|
||||
|
||||
```typescript
|
||||
async processPayment(dto: PaymentDto): Promise<ApiResponse<PaymentResult>> {
|
||||
// 1. 参数验证
|
||||
if (!dto.amount || dto.amount <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: '支付金额必须大于0',
|
||||
error_code: 'INVALID_AMOUNT'
|
||||
};
|
||||
async processPayment(paymentData: PaymentDto): Promise<PaymentResult> {
|
||||
// 1. 先处理所有可能的错误情况
|
||||
if (!paymentData.amount || paymentData.amount <= 0) {
|
||||
throw new BadRequestException('支付金额必须大于0');
|
||||
}
|
||||
|
||||
// 2. 业务规则验证
|
||||
const user = await this.usersService.findOne(dto.userId);
|
||||
if (!paymentData.userId) {
|
||||
throw new BadRequestException('用户ID不能为空');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findOne(paymentData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: '用户不存在',
|
||||
error_code: 'USER_NOT_FOUND'
|
||||
};
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 3. 状态检查
|
||||
if (user.status !== UserStatus.ACTIVE) {
|
||||
return {
|
||||
success: false,
|
||||
message: '用户状态不允许支付',
|
||||
error_code: 'USER_INACTIVE'
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 执行业务逻辑
|
||||
return this.executePayment(dto);
|
||||
// 2. 再处理正常的业务逻辑
|
||||
return this.executePayment(paymentData);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 测试驱动开发
|
||||
### 3. 日志驱动调试
|
||||
|
||||
```typescript
|
||||
// 先写测试
|
||||
describe('LoginService', () => {
|
||||
it('should login successfully with valid credentials', async () => {
|
||||
const dto = { identifier: 'test@example.com', password: 'password123' };
|
||||
const result = await loginService.login(dto);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toHaveProperty('accessToken');
|
||||
});
|
||||
async complexBusinessLogic(data: ComplexData): Promise<Result> {
|
||||
this.logger.debug('开始执行复杂业务逻辑', { data });
|
||||
|
||||
it('should return error with invalid credentials', async () => {
|
||||
const dto = { identifier: 'test@example.com', password: 'wrong' };
|
||||
const result = await loginService.login(dto);
|
||||
try {
|
||||
// 步骤1
|
||||
const step1Result = await this.step1(data);
|
||||
this.logger.debug('步骤1完成', { step1Result });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
// 再写实现
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
async login(dto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
// 实现逻辑
|
||||
// 步骤2
|
||||
const step2Result = await this.step2(step1Result);
|
||||
this.logger.debug('步骤2完成', { step2Result });
|
||||
|
||||
// 步骤3
|
||||
const finalResult = await this.step3(step2Result);
|
||||
this.logger.info('复杂业务逻辑执行成功', { finalResult });
|
||||
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
this.logger.error('复杂业务逻辑执行失败', { data, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 版本管理最佳实践
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 用户服务
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2025-01-07: 功能新增 - 添加用户头像上传功能 (v1.2.0)
|
||||
* - 2025-01-06: Bug修复 - 修复邮箱验证逻辑错误 (v1.1.1)
|
||||
* - 2025-01-05: 代码规范优化 - 统一异常处理格式 (v1.1.0)
|
||||
* - 2025-01-04: 功能新增 - 添加用户状态管理 (v1.1.0)
|
||||
* - 2025-01-03: 重构 - 重构用户认证逻辑 (v2.0.0)
|
||||
*
|
||||
* @version 1.2.0
|
||||
* @lastModified 2025-01-07
|
||||
*/
|
||||
```
|
||||
|
||||
**修改记录管理原则:**
|
||||
- ✅ **保持简洁** - 只保留最近5次修改
|
||||
- ✅ **定期清理** - 超出5条时删除最旧记录
|
||||
- ✅ **重要标注** - 重大版本更新可特别标注版本号
|
||||
- ✅ **描述清晰** - 每条记录都要说明具体改动内容
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
遵循开发规范能够:
|
||||
遵循后端开发规范能够:
|
||||
|
||||
1. **清晰的架构** - 四层架构确保职责分离
|
||||
2. **高质量代码** - 完整的注释和规范的实现
|
||||
3. **易于维护** - 清晰的文档和日志便于问题定位
|
||||
4. **团队协作** - 统一的规范减少沟通成本
|
||||
5. **系统稳定** - 完善的异常处理和防御性编程
|
||||
1. **提高代码质量** - 通过完整的注释和规范的实现
|
||||
2. **提升团队效率** - 统一的规范减少沟通成本
|
||||
3. **降低维护成本** - 清晰的文档和日志便于问题定位
|
||||
4. **增强系统稳定性** - 完善的异常处理和防御性编程
|
||||
5. **促进知识传承** - 详细的修改记录和版本管理
|
||||
|
||||
**记住:好的代码不仅要能运行,更要符合架构设计、易于理解、便于维护和扩展。**
|
||||
**记住:好的代码不仅要能运行,更要能被理解、维护和扩展。**
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [架构设计文档](../ARCHITECTURE.md) - 四层架构详解
|
||||
- [架构重构文档](../ARCHITECTURE_REFACTORING.md) - 架构迁移指南
|
||||
- [Git提交规范](./git_commit_guide.md) - 版本控制规范
|
||||
- [测试指南](./TESTING.md) - 测试规范和最佳实践
|
||||
- [命名规范](./naming_convention.md) - 代码命名规范
|
||||
- [NestJS 使用指南](./nestjs_guide.md) - 框架最佳实践
|
||||
- [Git 提交规范](./git_commit_guide.md) - 版本控制规范
|
||||
- [AI 辅助开发规范](./AI辅助开发规范指南.md) - AI 辅助开发指南
|
||||
@@ -82,7 +82,7 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot)
|
||||
```
|
||||
用户注册 (POST /auth/register)
|
||||
↓
|
||||
1. 创建游戏账号 (RegisterService.register)
|
||||
1. 创建游戏账号 (LoginService.register)
|
||||
↓
|
||||
2. 初始化 Zulip 管理员客户端
|
||||
↓
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
# 开发者代码检查规范
|
||||
|
||||
## 🎯 规范目标
|
||||
|
||||
本规范旨在确保代码质量、提升开发效率、维护项目一致性。通过系统化的代码检查流程,保障Whale Town游戏服务器项目的代码标准和技术质量。
|
||||
|
||||
## 📋 检查流程概述
|
||||
|
||||
代码检查分为7个步骤,必须按顺序执行,每步完成后等待确认才能进行下一步:
|
||||
|
||||
1. **步骤1:命名规范检查** - 文件、变量、类、常量命名规范
|
||||
2. **步骤2:注释规范检查** - 文件头、类、方法注释完整性
|
||||
3. **步骤3:代码质量检查** - 清理未使用代码、处理TODO项
|
||||
4. **步骤4:架构分层检查** - Core层和Business层职责分离
|
||||
5. **步骤5:测试覆盖检查** - 一对一测试映射、测试分离
|
||||
6. **步骤6:功能文档生成** - README文档、API接口文档
|
||||
7. **步骤7:代码提交** - Git变更校验、规范化提交
|
||||
|
||||
## 🔄 执行原则
|
||||
|
||||
### ⚠️ 强制要求
|
||||
- **分步执行**:每次只执行一个步骤,严禁跳步骤或合并执行
|
||||
- **等待确认**:每步完成后必须等待确认才能进行下一步
|
||||
- **修改验证**:每次修改文件后必须重新检查该步骤并提供验证报告
|
||||
- **🔥 修改后必须重新执行当前步骤**:如果在当前步骤中发生了任何修改行为,必须立即重新执行该步骤的完整检查
|
||||
- **问题修复后重检**:如果当前步骤出现问题需要修改时,必须在解决问题后重新执行该步骤
|
||||
|
||||
## 📚 详细检查标准
|
||||
|
||||
### 步骤1:命名规范检查
|
||||
|
||||
#### 文件和文件夹命名
|
||||
- **规则**:snake_case(下划线分隔)
|
||||
- **示例**:
|
||||
```
|
||||
✅ 正确:user_controller.ts, admin_operation_log_service.ts
|
||||
❌ 错误:UserController.ts, user-service.ts
|
||||
```
|
||||
|
||||
#### 变量和函数命名
|
||||
- **规则**:camelCase(小驼峰命名)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:const userName = 'test'; function getUserInfo() {}
|
||||
❌ 错误:const UserName = 'test'; function GetUserInfo() {}
|
||||
```
|
||||
|
||||
#### 类和接口命名
|
||||
- **规则**:PascalCase(大驼峰命名)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:class UserService {} interface GameConfig {}
|
||||
❌ 错误:class userService {} interface gameConfig {}
|
||||
```
|
||||
|
||||
#### 常量命名
|
||||
- **规则**:SCREAMING_SNAKE_CASE(全大写+下划线)
|
||||
- **示例**:
|
||||
```typescript
|
||||
✅ 正确:const MAX_RETRY_COUNT = 3; const SALT_ROUNDS = 10;
|
||||
❌ 错误:const maxRetryCount = 3; const saltRounds = 10;
|
||||
```
|
||||
|
||||
#### 文件夹结构扁平化
|
||||
- **≤3个文件**:必须扁平化处理
|
||||
- **≥4个文件**:通常保持独立文件夹
|
||||
- **测试文件位置**:测试文件与源文件放在同一目录
|
||||
|
||||
#### Core层命名规则
|
||||
- **业务支撑模块**:使用_core后缀(如location_broadcast_core/)
|
||||
- **通用工具模块**:不使用后缀(如redis/、logger/)
|
||||
|
||||
### 步骤2:注释规范检查
|
||||
|
||||
#### 文件头注释(必须包含)
|
||||
```typescript
|
||||
/**
|
||||
* 文件功能描述
|
||||
*
|
||||
* 功能描述:
|
||||
* - 主要功能点1
|
||||
* - 主要功能点2
|
||||
*
|
||||
* 职责分离:
|
||||
* - 职责描述1
|
||||
* - 职责描述2
|
||||
*
|
||||
* 最近修改:
|
||||
* - [用户日期]: 修改类型 - 修改内容 (修改者: [用户名称])
|
||||
* - [历史日期]: 修改类型 - 修改内容 (修改者: 历史修改者)
|
||||
*
|
||||
* @author [处理后的作者名称]
|
||||
* @version x.x.x
|
||||
* @since [创建日期]
|
||||
* @lastModified [用户日期]
|
||||
*/
|
||||
```
|
||||
|
||||
#### @author字段处理规范
|
||||
- **保留人名**:如果@author是人名,必须保留不变
|
||||
- **替换AI标识**:只有AI标识(kiro、ChatGPT、Claude、AI等)才可替换为用户名称
|
||||
|
||||
#### 修改记录规范
|
||||
- **修改类型**:代码规范优化、功能新增、功能修改、Bug修复、性能优化、重构
|
||||
- **最多保留5条**:超出时自动删除最旧记录
|
||||
- **版本号递增**:
|
||||
- 修订版本+1:代码规范优化、Bug修复
|
||||
- 次版本+1:功能新增、功能修改
|
||||
- 主版本+1:重构、架构变更
|
||||
|
||||
### 步骤3:代码质量检查
|
||||
|
||||
#### 未使用代码清理
|
||||
- 清理未使用的导入
|
||||
- 清理未使用的变量和方法
|
||||
- 删除未调用的私有方法
|
||||
|
||||
#### 常量定义规范
|
||||
- 使用SCREAMING_SNAKE_CASE
|
||||
- 提取魔法数字为常量
|
||||
- 统一常量命名
|
||||
|
||||
#### TODO项处理(强制要求)
|
||||
- **最终文件不能包含TODO项**
|
||||
- 必须真正实现功能或删除未完成代码
|
||||
|
||||
#### 方法长度检查
|
||||
- **建议**:方法不超过50行
|
||||
- **原则**:一个方法只做一件事
|
||||
- **拆分**:复杂方法拆分为多个小方法
|
||||
|
||||
### 步骤4:架构分层检查
|
||||
|
||||
#### Core层规范
|
||||
- **职责**:专注技术实现,不包含业务逻辑
|
||||
- **命名**:业务支撑模块使用_core后缀,通用工具模块不使用后缀
|
||||
- **依赖**:只能导入其他Core层模块和第三方技术库
|
||||
|
||||
#### Business层规范
|
||||
- **职责**:专注业务逻辑实现,不关心底层技术细节
|
||||
- **依赖**:可以导入Core层模块和其他Business层模块
|
||||
- **禁止**:不能直接使用底层技术实现
|
||||
|
||||
### 步骤5:测试覆盖检查
|
||||
|
||||
#### 严格一对一测试映射
|
||||
- **强制要求**:每个测试文件必须严格对应一个源文件
|
||||
- **禁止多对一**:不允许一个测试文件测试多个源文件
|
||||
- **命名对应**:测试文件名必须与源文件名完全对应
|
||||
|
||||
#### 需要测试文件的类型
|
||||
```typescript
|
||||
✅ 必须有测试文件:
|
||||
- *.service.ts # Service类
|
||||
- *.controller.ts # Controller类
|
||||
- *.gateway.ts # Gateway类
|
||||
- *.guard.ts # Guard类
|
||||
- *.interceptor.ts # Interceptor类
|
||||
- *.middleware.ts # Middleware类
|
||||
|
||||
❌ 不需要测试文件:
|
||||
- *.dto.ts # DTO类
|
||||
- *.interface.ts # Interface文件
|
||||
- *.constants.ts # Constants文件
|
||||
```
|
||||
|
||||
#### 测试分离架构
|
||||
```
|
||||
test/
|
||||
├── integration/ # 集成测试
|
||||
├── e2e/ # 端到端测试
|
||||
├── performance/ # 性能测试
|
||||
├── property/ # 属性测试
|
||||
└── fixtures/ # 测试数据和工具
|
||||
```
|
||||
|
||||
### 步骤6:功能文档生成
|
||||
|
||||
#### README文档结构
|
||||
每个功能模块文件夹都必须有README.md文档,包含:
|
||||
- 模块功能描述
|
||||
- 对外提供的接口
|
||||
- 对外API接口(如适用)
|
||||
- WebSocket事件接口(如适用)
|
||||
- 使用的项目内部依赖
|
||||
- 核心特性
|
||||
- 潜在风险
|
||||
|
||||
#### 游戏服务器特殊要求
|
||||
- **WebSocket Gateway**:详细的事件接口文档
|
||||
- **双模式服务**:模式特点和切换指南
|
||||
- **属性测试**:测试策略说明
|
||||
|
||||
### 步骤7:代码提交
|
||||
|
||||
#### Git变更检查
|
||||
- 检查Git状态和变更内容
|
||||
- 校验文件修改记录与实际修改内容一致性
|
||||
- 确认修改记录、版本号、时间戳正确更新
|
||||
|
||||
#### 分支管理规范
|
||||
```bash
|
||||
# 代码规范优化分支
|
||||
feature/code-standard-optimization-[日期]
|
||||
|
||||
# Bug修复分支
|
||||
fix/[具体问题描述]
|
||||
|
||||
# 功能新增分支
|
||||
feature/[功能名称]
|
||||
|
||||
# 重构分支
|
||||
refactor/[模块名称]
|
||||
```
|
||||
|
||||
#### 提交信息规范
|
||||
```bash
|
||||
<类型>:<简短描述>
|
||||
|
||||
[可选的详细描述]
|
||||
```
|
||||
|
||||
提交类型:
|
||||
- `style`:代码规范优化
|
||||
- `refactor`:代码重构
|
||||
- `feat`:新功能
|
||||
- `fix`:Bug修复
|
||||
- `perf`:性能优化
|
||||
- `test`:测试相关
|
||||
- `docs`:文档更新
|
||||
|
||||
## 🎮 游戏服务器特殊要求
|
||||
|
||||
### WebSocket相关
|
||||
- **Gateway文件**:必须有完整的连接、消息处理测试
|
||||
- **实时通信**:心跳检测、重连机制、性能监控
|
||||
- **事件文档**:详细的输入输出格式说明
|
||||
|
||||
### 双模式架构
|
||||
- **内存服务和数据库服务**:都需要完整测试覆盖
|
||||
- **行为一致性**:确保两种模式行为完全一致
|
||||
- **切换机制**:提供模式切换指南和数据迁移工具
|
||||
|
||||
### 属性测试
|
||||
- **管理员模块**:使用fast-check进行属性测试
|
||||
- **随机化测试**:验证边界条件和异常处理
|
||||
- **测试策略**:详细的属性测试实现说明
|
||||
|
||||
## 📋 统一报告模板
|
||||
|
||||
每步完成后使用此模板报告:
|
||||
|
||||
```
|
||||
## 步骤X:[步骤名称]检查报告
|
||||
|
||||
### 🔍 检查结果
|
||||
[发现的问题列表]
|
||||
|
||||
### 🛠️ 修正方案
|
||||
[具体修正建议]
|
||||
|
||||
### ✅ 完成状态
|
||||
- 检查项1 ✓/✗
|
||||
- 检查项2 ✓/✗
|
||||
|
||||
**请确认修正方案,确认后进行下一步骤**
|
||||
```
|
||||
|
||||
## 🚨 全局约束
|
||||
|
||||
### 文件修改记录规范
|
||||
每次执行完修改后,文件顶部都需要更新:
|
||||
- 添加修改记录(最多保留5条)
|
||||
- 更新版本号(按规则递增)
|
||||
- 更新@lastModified字段
|
||||
- 正确处理@author字段
|
||||
|
||||
### 时间更新规则
|
||||
- **仅检查不修改**:不更新@lastModified字段
|
||||
- **实际修改才更新**:只有真正修改了文件内容时才更新
|
||||
- **Git变更检测**:通过git检查文件是否有实际变更
|
||||
|
||||
### 修改验证流程
|
||||
任何步骤中发生修改行为后,必须立即重新执行该步骤:
|
||||
```
|
||||
步骤执行中 → 发现问题 → 执行修改 → 🔥 立即重新执行该步骤 → 验证无遗漏 → 用户确认 → 下一步骤
|
||||
```
|
||||
|
||||
## 🔧 AI-Reading使用指南
|
||||
|
||||
### 什么是AI-Reading
|
||||
|
||||
AI-Reading是一套系统化的代码检查执行指南,专门为Whale Town游戏服务器项目设计。它提供了完整的7步代码检查流程,确保代码质量和项目规范的一致性。
|
||||
|
||||
### 使用场景
|
||||
|
||||
#### 适用情况
|
||||
- **新功能开发完成后**:确保新代码符合项目规范
|
||||
- **Bug修复后**:验证修复代码的质量和规范性
|
||||
- **代码重构时**:保证重构后代码的一致性和质量
|
||||
- **代码审查前**:提前发现和解决规范问题
|
||||
- **项目维护期**:定期检查和优化代码质量
|
||||
|
||||
#### 不适用情况
|
||||
- **紧急热修复**:紧急生产问题修复时可简化流程
|
||||
- **实验性代码**:概念验证或原型开发阶段
|
||||
- **第三方代码集成**:外部库或组件的集成
|
||||
|
||||
### 使用方法
|
||||
|
||||
#### 1. 准备阶段
|
||||
在开始检查前,必须收集以下信息:
|
||||
- **用户当前日期**:用于修改记录和时间戳更新
|
||||
- **用户名称**:用于@author字段处理和修改记录
|
||||
|
||||
#### 2. 执行流程
|
||||
```
|
||||
用户请求代码检查
|
||||
↓
|
||||
收集用户信息(日期、名称)
|
||||
↓
|
||||
识别项目特性(NestJS游戏服务器)
|
||||
↓
|
||||
按顺序执行7个步骤
|
||||
↓
|
||||
每步完成后等待用户确认
|
||||
↓
|
||||
如有修改立即重新执行当前步骤
|
||||
```
|
||||
|
||||
#### 3. 使用AI-Reading的具体步骤
|
||||
|
||||
**第一步:启动检查**
|
||||
```
|
||||
请使用ai-reading对[模块名称]进行代码检查
|
||||
当前日期:[YYYY-MM-DD]
|
||||
用户名称:[您的名称]
|
||||
```
|
||||
|
||||
**第二步:逐步执行**
|
||||
AI会按照以下顺序执行:
|
||||
1. 读取对应步骤的详细指导文档
|
||||
2. 执行该步骤的所有检查项
|
||||
3. 提供详细的检查报告
|
||||
4. 等待用户确认后进行下一步
|
||||
|
||||
**第三步:处理修改**
|
||||
如果某步骤需要修改代码:
|
||||
1. AI会执行必要的修改操作
|
||||
2. 更新文件的修改记录和版本信息
|
||||
3. 立即重新执行该步骤进行验证
|
||||
4. 提供验证报告确认无遗漏问题
|
||||
|
||||
**第四步:完成检查**
|
||||
所有7个步骤完成后:
|
||||
1. 提供完整的检查总结报告
|
||||
2. 确认所有问题已解决
|
||||
3. 代码已准备好进行提交或部署
|
||||
|
||||
### 使用技巧
|
||||
|
||||
#### 高效使用
|
||||
- **批量检查**:可以一次性检查整个模块或功能
|
||||
- **增量检查**:只检查修改的文件和相关依赖
|
||||
- **定期检查**:建议每周对核心模块进行一次完整检查
|
||||
|
||||
#### 注意事项
|
||||
- **不要跳步骤**:必须按顺序完成所有步骤
|
||||
- **确认每一步**:每步完成后仔细检查报告再确认
|
||||
- **保存检查记录**:保留检查报告用于后续参考
|
||||
- **及时处理问题**:发现问题立即修复,不要积累
|
||||
|
||||
#### 常见问题处理
|
||||
- **检查时间过长**:可以分模块进行,不必一次性检查整个项目
|
||||
- **修改冲突**:如果与其他开发者的修改冲突,先解决冲突再继续检查
|
||||
- **测试失败**:如果测试不通过,必须先修复测试再继续后续步骤
|
||||
|
||||
### 最佳实践
|
||||
|
||||
#### 团队协作
|
||||
- **统一标准**:团队成员都使用相同的AI-Reading流程
|
||||
- **代码审查**:在代码审查前先完成AI-Reading检查
|
||||
- **知识分享**:定期分享AI-Reading发现的问题和解决方案
|
||||
|
||||
#### 质量保证
|
||||
- **持续改进**:根据检查结果不断优化代码规范
|
||||
- **文档同步**:确保文档与代码实现保持一致
|
||||
- **测试覆盖**:通过AI-Reading确保测试覆盖率达标
|
||||
|
||||
#### 效率提升
|
||||
- **自动化集成**:考虑将AI-Reading集成到CI/CD流程
|
||||
- **模板使用**:使用标准模板减少重复工作
|
||||
- **工具辅助**:结合IDE插件和代码格式化工具
|
||||
|
||||
通过正确使用AI-Reading,可以显著提升代码质量,减少bug数量,提高开发效率,确保项目的长期可维护性。
|
||||
|
||||
---
|
||||
|
||||
**重要提醒**:使用AI-Reading时,请严格按照7步流程执行,不要跳过任何步骤,确保每一步都得到充分验证后再进行下一步。
|
||||
@@ -1,5 +1,4 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
roots: ['<rootDir>/src', '<rootDir>/test'],
|
||||
testRegex: '.*\\.(spec|e2e-spec|integration-spec|perf-spec)\\.ts$',
|
||||
@@ -14,16 +13,4 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^src/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
// 添加异步处理配置
|
||||
testTimeout: 10000,
|
||||
// 强制退出以避免挂起
|
||||
forceExit: true,
|
||||
// 检测打开的句柄
|
||||
detectOpenHandles: true,
|
||||
// 处理 ES 模块
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@faker-js/faker)/)',
|
||||
],
|
||||
// 设置测试环境变量
|
||||
setupFilesAfterEnv: ['<rootDir>/test-setup.js'],
|
||||
};
|
||||
19
package.json
19
package.json
@@ -11,11 +11,9 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts --runInBand",
|
||||
"test:e2e": "cross-env RUN_E2E_TESTS=true jest --testPathPattern=e2e.spec.ts",
|
||||
"test:unit": "jest --testPathPattern=spec.ts --testPathIgnorePatterns=e2e.spec.ts",
|
||||
"test:integration": "jest --testPathPattern=integration.spec.ts --runInBand",
|
||||
"test:property": "jest --testPathPattern=property.spec.ts",
|
||||
"test:all": "cross-env RUN_E2E_TESTS=true jest --runInBand"
|
||||
"test:all": "cross-env RUN_E2E_TESTS=true jest"
|
||||
},
|
||||
"keywords": [
|
||||
"game",
|
||||
@@ -27,36 +25,33 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/platform-ws": "^11.1.11",
|
||||
"@nestjs/platform-express": "^10.4.20",
|
||||
"@nestjs/platform-socket.io": "^10.4.20",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^11.2.3",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@nestjs/websockets": "^10.4.20",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"nock": "^14.0.10",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pino": "^10.1.0",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -74,11 +69,11 @@
|
||||
"@types/node": "^20.19.27",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"fast-check": "^4.5.2",
|
||||
"jest": "^29.7.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
#!/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();
|
||||
@@ -6,18 +6,14 @@ import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { LoggerModule } from './core/utils/logger/logger.module';
|
||||
import { UsersModule } from './core/db/users/users.module';
|
||||
import { ZulipAccountsModule } from './core/db/zulip_accounts/zulip_accounts.module';
|
||||
import { LoginCoreModule } from './core/login_core/login_core.module';
|
||||
import { AuthGatewayModule } from './gateway/auth/auth.gateway.module';
|
||||
import { ChatGatewayModule } from './gateway/chat/chat.gateway.module';
|
||||
import { ZulipGatewayModule } from './gateway/zulip/zulip.gateway.module';
|
||||
import { AuthModule } from './business/auth/auth.module';
|
||||
import { ZulipModule } from './business/zulip/zulip.module';
|
||||
import { RedisModule } from './core/redis/redis.module';
|
||||
import { AdminModule } from './business/admin/admin.module';
|
||||
import { UserMgmtModule } from './business/user_mgmt/user_mgmt.module';
|
||||
import { SecurityCoreModule } from './core/security_core/security_core.module';
|
||||
import { LocationBroadcastModule } from './business/location_broadcast/location_broadcast.module';
|
||||
import { NoticeModule } from './business/notice/notice.module';
|
||||
import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware';
|
||||
import { ContentTypeMiddleware } from './core/security_core/content_type.middleware';
|
||||
|
||||
@@ -63,8 +59,6 @@ function isDatabaseConfigured(): boolean {
|
||||
database: process.env.DB_NAME,
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: false,
|
||||
// 字符集配置 - 支持中文和emoji
|
||||
charset: 'utf8mb4',
|
||||
// 添加连接超时和重试配置
|
||||
connectTimeout: 10000,
|
||||
retryAttempts: 3,
|
||||
@@ -73,18 +67,13 @@ function isDatabaseConfigured(): boolean {
|
||||
] : []),
|
||||
// 根据数据库配置选择用户模块模式
|
||||
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
|
||||
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
|
||||
ZulipAccountsModule.forRoot(),
|
||||
LoginCoreModule,
|
||||
AuthGatewayModule, // 认证网关模块
|
||||
ChatGatewayModule, // 聊天网关模块
|
||||
ZulipGatewayModule, // Zulip网关模块(HTTP API接口)
|
||||
ZulipModule, // Zulip业务模块(业务逻辑)
|
||||
AuthModule,
|
||||
ZulipModule,
|
||||
UserMgmtModule,
|
||||
AdminModule,
|
||||
SecurityCoreModule,
|
||||
LocationBroadcastModule,
|
||||
NoticeModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -19,14 +19,13 @@
|
||||
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 代码质量优化 - 将同步文件系统操作改为异步操作,避免阻塞事件循环 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
* - 2026-01-08: 注释规范优化 - 补充方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.4
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-09
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
|
||||
@@ -231,7 +230,7 @@ export class AdminController {
|
||||
const logDir = this.adminService.getLogDirAbsolutePath();
|
||||
|
||||
// 验证日志目录
|
||||
const dirValidation = await this.validateLogDirectory(logDir, res);
|
||||
const dirValidation = this.validateLogDirectory(logDir, res);
|
||||
if (!dirValidation.isValid) {
|
||||
return;
|
||||
}
|
||||
@@ -250,18 +249,19 @@ export class AdminController {
|
||||
* @param res 响应对象
|
||||
* @returns 验证结果
|
||||
*/
|
||||
private async validateLogDirectory(logDir: string, res: Response): Promise<{ isValid: boolean }> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(logDir);
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||||
return { isValid: false };
|
||||
}
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
private validateLogDirectory(logDir: string, res: Response): { isValid: boolean } {
|
||||
if (!fs.existsSync(logDir)) {
|
||||
res.status(404).json({ success: false, message: '日志目录不存在' });
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(logDir);
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||
import { UsersModule } from '../../core/db/users/users.module';
|
||||
import { UserProfilesModule } from '../../core/db/user_profiles/user_profiles.module';
|
||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
@@ -54,7 +55,7 @@ function isDatabaseConfigured(): boolean {
|
||||
UsersModule,
|
||||
// 根据数据库配置选择UserProfiles模块模式
|
||||
isDatabaseConfigured() ? UserProfilesModule.forDatabase() : UserProfilesModule.forMemory(),
|
||||
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||
ZulipAccountsModule,
|
||||
// 注册AdminOperationLog实体
|
||||
TypeOrmModule.forFeature([AdminOperationLog])
|
||||
],
|
||||
|
||||
@@ -1,493 +0,0 @@
|
||||
/**
|
||||
* AdminDatabaseController 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试管理员数据库管理控制器的所有HTTP端点
|
||||
* - 验证请求参数处理和响应格式
|
||||
* - 测试权限验证和异常处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP层测试,不涉及业务逻辑实现
|
||||
* - Mock业务服务,专注控制器逻辑
|
||||
* - 验证请求响应的正确性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 功能新增 - 创建AdminDatabaseController单元测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-09
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
|
||||
describe('AdminDatabaseController', () => {
|
||||
let controller: AdminDatabaseController;
|
||||
let databaseService: jest.Mocked<DatabaseManagementService>;
|
||||
|
||||
const mockDatabaseService = {
|
||||
// User management methods
|
||||
getUserList: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
searchUsers: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
deleteUser: jest.fn(),
|
||||
|
||||
// User profile management methods
|
||||
getUserProfileList: jest.fn(),
|
||||
getUserProfileById: jest.fn(),
|
||||
getUserProfilesByMap: jest.fn(),
|
||||
createUserProfile: jest.fn(),
|
||||
updateUserProfile: jest.fn(),
|
||||
deleteUserProfile: jest.fn(),
|
||||
|
||||
// Zulip account management methods
|
||||
getZulipAccountList: jest.fn(),
|
||||
getZulipAccountById: jest.fn(),
|
||||
getZulipAccountStatistics: jest.fn(),
|
||||
createZulipAccount: jest.fn(),
|
||||
updateZulipAccount: jest.fn(),
|
||||
deleteZulipAccount: jest.fn(),
|
||||
batchUpdateZulipAccountStatus: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAdminOperationLogService = {
|
||||
createLog: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminDatabaseController],
|
||||
providers: [
|
||||
{
|
||||
provide: DatabaseManagementService,
|
||||
useValue: mockDatabaseService,
|
||||
},
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useValue: mockAdminOperationLogService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AdminDatabaseController>(AdminDatabaseController);
|
||||
databaseService = module.get(DatabaseManagementService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('User Management', () => {
|
||||
describe('getUserList', () => {
|
||||
it('should get user list with default pagination', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { items: [], total: 0, limit: 20, offset: 0, has_more: false },
|
||||
message: '用户列表获取成功'
|
||||
};
|
||||
|
||||
databaseService.getUserList.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getUserList(20, 0);
|
||||
|
||||
expect(databaseService.getUserList).toHaveBeenCalledWith(20, 0);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should get user list with custom pagination', async () => {
|
||||
const query = { limit: 50, offset: 10 };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { items: [], total: 0, limit: 50, offset: 10, has_more: false },
|
||||
message: '用户列表获取成功'
|
||||
};
|
||||
|
||||
databaseService.getUserList.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getUserList(20, 0);
|
||||
|
||||
expect(databaseService.getUserList).toHaveBeenCalledWith(20, 0);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserById', () => {
|
||||
it('should get user by id successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', username: 'testuser' },
|
||||
message: '用户详情获取成功'
|
||||
};
|
||||
|
||||
databaseService.getUserById.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getUserById('1');
|
||||
|
||||
expect(databaseService.getUserById).toHaveBeenCalledWith(BigInt(1));
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchUsers', () => {
|
||||
it('should search users successfully', async () => {
|
||||
const query = { search: 'admin', limit: 10 };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { items: [], total: 0, limit: 10, offset: 0, has_more: false },
|
||||
message: '用户搜索成功'
|
||||
};
|
||||
|
||||
databaseService.searchUsers.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.searchUsers('admin', 20);
|
||||
|
||||
expect(databaseService.searchUsers).toHaveBeenCalledWith('admin', 20);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create user successfully', async () => {
|
||||
const userData = { username: 'newuser', nickname: 'New User', email: 'new@test.com' };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', ...userData },
|
||||
message: '用户创建成功'
|
||||
};
|
||||
|
||||
databaseService.createUser.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.createUser(userData);
|
||||
|
||||
expect(databaseService.createUser).toHaveBeenCalledWith(userData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user successfully', async () => {
|
||||
const updateData = { nickname: 'Updated User' };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', nickname: 'Updated User' },
|
||||
message: '用户更新成功'
|
||||
};
|
||||
|
||||
databaseService.updateUser.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.updateUser('1', updateData);
|
||||
|
||||
expect(databaseService.updateUser).toHaveBeenCalledWith(BigInt(1), updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('should delete user successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { deleted: true, id: '1' },
|
||||
message: '用户删除成功'
|
||||
};
|
||||
|
||||
databaseService.deleteUser.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.deleteUser('1');
|
||||
|
||||
expect(databaseService.deleteUser).toHaveBeenCalledWith(BigInt(1));
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Profile Management', () => {
|
||||
describe('getUserProfileList', () => {
|
||||
it('should get user profile list successfully', async () => {
|
||||
const query = { limit: 20, offset: 0 };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { items: [], total: 0, limit: 20, offset: 0, has_more: false },
|
||||
message: '用户档案列表获取成功'
|
||||
};
|
||||
|
||||
databaseService.getUserProfileList.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getUserProfileList(20, 0);
|
||||
|
||||
expect(databaseService.getUserProfileList).toHaveBeenCalledWith(20, 0);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserProfileById', () => {
|
||||
it('should get user profile by id successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', user_id: '1', bio: 'Test bio' },
|
||||
message: '用户档案详情获取成功'
|
||||
};
|
||||
|
||||
databaseService.getUserProfileById.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getUserProfileById('1');
|
||||
|
||||
expect(databaseService.getUserProfileById).toHaveBeenCalledWith(BigInt(1));
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserProfilesByMap', () => {
|
||||
it('should get user profiles by map successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { items: [], total: 0, limit: 20, offset: 0, has_more: false },
|
||||
message: 'plaza 的用户档案列表获取成功'
|
||||
};
|
||||
|
||||
databaseService.getUserProfilesByMap.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getUserProfilesByMap('plaza', 20, 0);
|
||||
|
||||
expect(databaseService.getUserProfilesByMap).toHaveBeenCalledWith('plaza', 20, 0);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserProfile', () => {
|
||||
it('should create user profile successfully', async () => {
|
||||
const profileData = {
|
||||
user_id: '1',
|
||||
bio: 'Test bio',
|
||||
resume_content: 'Test resume',
|
||||
tags: '["tag1"]',
|
||||
social_links: '{"github":"test"}',
|
||||
skin_id: '1',
|
||||
current_map: 'plaza',
|
||||
pos_x: 100,
|
||||
pos_y: 200,
|
||||
status: 1
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', ...profileData },
|
||||
message: '用户档案创建成功'
|
||||
};
|
||||
|
||||
databaseService.createUserProfile.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.createUserProfile(profileData);
|
||||
|
||||
expect(databaseService.createUserProfile).toHaveBeenCalledWith(profileData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserProfile', () => {
|
||||
it('should update user profile successfully', async () => {
|
||||
const updateData = { bio: 'Updated bio' };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', bio: 'Updated bio' },
|
||||
message: '用户档案更新成功'
|
||||
};
|
||||
|
||||
databaseService.updateUserProfile.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.updateUserProfile('1', updateData);
|
||||
|
||||
expect(databaseService.updateUserProfile).toHaveBeenCalledWith(BigInt(1), updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserProfile', () => {
|
||||
it('should delete user profile successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { deleted: true, id: '1' },
|
||||
message: '用户档案删除成功'
|
||||
};
|
||||
|
||||
databaseService.deleteUserProfile.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.deleteUserProfile('1');
|
||||
|
||||
expect(databaseService.deleteUserProfile).toHaveBeenCalledWith(BigInt(1));
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zulip Account Management', () => {
|
||||
describe('getZulipAccountList', () => {
|
||||
it('should get zulip account list successfully', async () => {
|
||||
const query = { limit: 20, offset: 0 };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { items: [], total: 0, limit: 20, offset: 0, has_more: false },
|
||||
message: 'Zulip账号关联列表获取成功'
|
||||
};
|
||||
|
||||
databaseService.getZulipAccountList.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getZulipAccountList(20, 0);
|
||||
|
||||
expect(databaseService.getZulipAccountList).toHaveBeenCalledWith(20, 0);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipAccountById', () => {
|
||||
it('should get zulip account by id successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', gameUserId: '1', zulipEmail: 'test@zulip.com' },
|
||||
message: 'Zulip账号关联详情获取成功'
|
||||
};
|
||||
|
||||
databaseService.getZulipAccountById.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getZulipAccountById('1');
|
||||
|
||||
expect(databaseService.getZulipAccountById).toHaveBeenCalledWith('1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipAccountStatistics', () => {
|
||||
it('should get zulip account statistics successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { active: 10, inactive: 5, total: 15 },
|
||||
message: 'Zulip账号关联统计获取成功'
|
||||
};
|
||||
|
||||
databaseService.getZulipAccountStatistics.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.getZulipAccountStatistics();
|
||||
|
||||
expect(databaseService.getZulipAccountStatistics).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createZulipAccount', () => {
|
||||
it('should create zulip account successfully', async () => {
|
||||
const accountData = {
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test User',
|
||||
zulipApiKeyEncrypted: 'encrypted_key'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', ...accountData },
|
||||
message: 'Zulip账号关联创建成功'
|
||||
};
|
||||
|
||||
databaseService.createZulipAccount.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.createZulipAccount(accountData);
|
||||
|
||||
expect(databaseService.createZulipAccount).toHaveBeenCalledWith(accountData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateZulipAccount', () => {
|
||||
it('should update zulip account successfully', async () => {
|
||||
const updateData = { zulipFullName: 'Updated Name' };
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { id: '1', zulipFullName: 'Updated Name' },
|
||||
message: 'Zulip账号关联更新成功'
|
||||
};
|
||||
|
||||
databaseService.updateZulipAccount.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.updateZulipAccount('1', updateData);
|
||||
|
||||
expect(databaseService.updateZulipAccount).toHaveBeenCalledWith('1', updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteZulipAccount', () => {
|
||||
it('should delete zulip account successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: { deleted: true, id: '1' },
|
||||
message: 'Zulip账号关联删除成功'
|
||||
};
|
||||
|
||||
databaseService.deleteZulipAccount.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.deleteZulipAccount('1');
|
||||
|
||||
expect(databaseService.deleteZulipAccount).toHaveBeenCalledWith('1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchUpdateZulipAccountStatus', () => {
|
||||
it('should batch update zulip account status successfully', async () => {
|
||||
const batchData = {
|
||||
ids: ['1', '2', '3'],
|
||||
status: 'active' as const,
|
||||
reason: 'Batch activation'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
success_count: 3,
|
||||
failed_count: 0,
|
||||
total_count: 3,
|
||||
reason: 'Batch activation'
|
||||
},
|
||||
message: 'Zulip账号关联批量状态更新完成,成功:3,失败:0'
|
||||
};
|
||||
|
||||
databaseService.batchUpdateZulipAccountStatus.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.batchUpdateZulipAccountStatus(batchData);
|
||||
|
||||
expect(databaseService.batchUpdateZulipAccountStatus).toHaveBeenCalledWith(
|
||||
['1', '2', '3'],
|
||||
'active',
|
||||
'Batch activation'
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Check', () => {
|
||||
describe('healthCheck', () => {
|
||||
it('should return health status successfully', async () => {
|
||||
const result = await controller.healthCheck();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.services).toBeDefined();
|
||||
expect(result.data.services.users).toBe('connected');
|
||||
expect(result.data.services.user_profiles).toBe('connected');
|
||||
expect(result.data.services.zulip_accounts).toBe('connected');
|
||||
expect(result.message).toBe('数据库管理系统运行正常');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -64,11 +64,7 @@ import {
|
||||
AdminUpdateUserDto,
|
||||
AdminBatchUpdateStatusDto,
|
||||
AdminDatabaseResponseDto,
|
||||
AdminHealthCheckResponseDto,
|
||||
AdminCreateUserProfileDto,
|
||||
AdminUpdateUserProfileDto,
|
||||
AdminCreateZulipAccountDto,
|
||||
AdminUpdateZulipAccountDto
|
||||
AdminHealthCheckResponseDto
|
||||
} from './admin_database.dto';
|
||||
import { PAGINATION_LIMITS, REQUEST_ID_PREFIXES } from './admin_constants';
|
||||
import { safeLimitValue, createSuccessResponse, getCurrentTimestamp } from './admin_utils';
|
||||
@@ -243,12 +239,12 @@ export class AdminDatabaseController {
|
||||
summary: '创建用户档案',
|
||||
description: '为指定用户创建档案信息'
|
||||
})
|
||||
@ApiBody({ type: AdminCreateUserProfileDto, description: '用户档案创建数据' })
|
||||
@ApiBody({ type: 'AdminCreateUserProfileDto', description: '用户档案创建数据' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '用户档案已存在' })
|
||||
@Post('user-profiles')
|
||||
async createUserProfile(@Body() createProfileDto: AdminCreateUserProfileDto): Promise<AdminApiResponse> {
|
||||
async createUserProfile(@Body() createProfileDto: any): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.createUserProfile(createProfileDto);
|
||||
}
|
||||
|
||||
@@ -257,13 +253,13 @@ export class AdminDatabaseController {
|
||||
description: '根据档案ID更新用户档案信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '档案ID', example: '1' })
|
||||
@ApiBody({ type: AdminUpdateUserProfileDto, description: '用户档案更新数据' })
|
||||
@ApiBody({ type: 'AdminUpdateUserProfileDto', description: '用户档案更新数据' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '档案不存在' })
|
||||
@Put('user-profiles/:id')
|
||||
async updateUserProfile(
|
||||
@Param('id') id: string,
|
||||
@Body() updateProfileDto: AdminUpdateUserProfileDto
|
||||
@Body() updateProfileDto: any
|
||||
): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.updateUserProfile(BigInt(id), updateProfileDto);
|
||||
}
|
||||
@@ -324,12 +320,12 @@ export class AdminDatabaseController {
|
||||
summary: '创建Zulip账号关联',
|
||||
description: '创建游戏用户与Zulip账号的关联'
|
||||
})
|
||||
@ApiBody({ type: AdminCreateZulipAccountDto, description: 'Zulip账号关联创建数据' })
|
||||
@ApiBody({ type: 'AdminCreateZulipAccountDto', description: 'Zulip账号关联创建数据' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '关联已存在' })
|
||||
@Post('zulip-accounts')
|
||||
async createZulipAccount(@Body() createAccountDto: AdminCreateZulipAccountDto): Promise<AdminApiResponse> {
|
||||
async createZulipAccount(@Body() createAccountDto: any): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.createZulipAccount(createAccountDto);
|
||||
}
|
||||
|
||||
@@ -338,13 +334,13 @@ export class AdminDatabaseController {
|
||||
description: '根据关联ID更新Zulip账号关联信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '关联ID', example: '1' })
|
||||
@ApiBody({ type: AdminUpdateZulipAccountDto, description: 'Zulip账号关联更新数据' })
|
||||
@ApiBody({ type: 'AdminUpdateZulipAccountDto', description: 'Zulip账号关联更新数据' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '关联不存在' })
|
||||
@Put('zulip-accounts/:id')
|
||||
async updateZulipAccount(
|
||||
@Param('id') id: string,
|
||||
@Body() updateAccountDto: AdminUpdateZulipAccountDto
|
||||
@Body() updateAccountDto: any
|
||||
): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.updateZulipAccount(id, updateAccountDto);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../admin.guard';
|
||||
import { UserStatus } from '../../../core/db/users/user_status.enum';
|
||||
|
||||
describe('Admin Database Management Integration Tests', () => {
|
||||
let app: INestApplication;
|
||||
@@ -66,7 +66,7 @@ describe('Admin Database Management Integration Tests', () => {
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: '测试用户',
|
||||
zulipApiKeyEncrypted: 'encrypted_test_key',
|
||||
status: 'active' as const
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -316,7 +316,7 @@ describe('Admin Database Management Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('应该成功更新Zulip账号关联', async () => {
|
||||
const updateData = { status: 'inactive' as const };
|
||||
const updateData = { status: 'inactive' };
|
||||
const result = await controller.updateZulipAccount('1', updateData);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
/**
|
||||
* AdminDatabaseExceptionFilter 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试管理员数据库异常过滤器的所有功能
|
||||
* - 验证异常处理和错误响应格式化的正确性
|
||||
* - 测试各种异常类型的处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 异常过滤器逻辑测试,不涉及具体业务
|
||||
* - Mock HTTP上下文,专注过滤器功能
|
||||
* - 验证错误响应的格式和内容
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 功能新增 - 创建AdminDatabaseExceptionFilter单元测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-09
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ArgumentsHost, HttpStatus, HttpException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
UnprocessableEntityException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
|
||||
describe('AdminDatabaseExceptionFilter', () => {
|
||||
let filter: AdminDatabaseExceptionFilter;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AdminDatabaseExceptionFilter],
|
||||
}).compile();
|
||||
|
||||
filter = module.get<AdminDatabaseExceptionFilter>(AdminDatabaseExceptionFilter);
|
||||
});
|
||||
|
||||
const createMockArgumentsHost = (requestData: any = {}) => {
|
||||
const mockRequest = {
|
||||
method: 'POST',
|
||||
url: '/admin/database/users',
|
||||
ip: '127.0.0.1',
|
||||
get: jest.fn().mockReturnValue('test-user-agent'),
|
||||
body: { username: 'testuser' },
|
||||
query: { limit: '10' },
|
||||
...requestData,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
|
||||
const mockHost = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
getResponse: () => mockResponse,
|
||||
}),
|
||||
} as ArgumentsHost;
|
||||
|
||||
return { mockHost, mockRequest, mockResponse };
|
||||
};
|
||||
|
||||
describe('catch', () => {
|
||||
it('should handle BadRequestException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new BadRequestException('Invalid input data');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Invalid input data',
|
||||
error_code: 'BAD_REQUEST',
|
||||
path: '/admin/database/users',
|
||||
method: 'POST',
|
||||
timestamp: expect.any(String),
|
||||
request_id: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle UnauthorizedException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new UnauthorizedException('Access denied');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Access denied',
|
||||
error_code: 'UNAUTHORIZED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ForbiddenException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new ForbiddenException('Insufficient permissions');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Insufficient permissions',
|
||||
error_code: 'FORBIDDEN',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle NotFoundException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new NotFoundException('User not found');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
error_code: 'NOT_FOUND',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ConflictException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new ConflictException('Username already exists');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Username already exists',
|
||||
error_code: 'CONFLICT',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle UnprocessableEntityException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new UnprocessableEntityException('Validation failed');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
error_code: 'UNPROCESSABLE_ENTITY',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle InternalServerErrorException', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new InternalServerErrorException('Database connection failed');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Database connection failed',
|
||||
error_code: 'INTERNAL_SERVER_ERROR',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unknown exceptions', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new Error('Unknown error');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: '系统内部错误,请稍后重试',
|
||||
error_code: 'INTERNAL_SERVER_ERROR',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle exception with object response', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new BadRequestException({
|
||||
message: 'Validation error',
|
||||
details: [
|
||||
{ field: 'username', constraint: 'minLength', received_value: 'ab' }
|
||||
]
|
||||
});
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
error_code: 'BAD_REQUEST',
|
||||
details: [
|
||||
{ field: 'username', constraint: 'minLength', received_value: 'ab' }
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle exception with nested error message', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new BadRequestException({
|
||||
error: 'Custom error message'
|
||||
});
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Custom error message',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize sensitive fields in request body', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost({
|
||||
body: {
|
||||
username: 'testuser',
|
||||
password: 'secret123',
|
||||
api_key: 'sensitive-key'
|
||||
}
|
||||
});
|
||||
const exception = new BadRequestException('Invalid data');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
// 验证响应被正确处理(敏感字段在日志中被清理,但不影响响应)
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Invalid data',
|
||||
error_code: 'BAD_REQUEST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing user agent', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
mockHost.switchToHttp().getRequest().get = jest.fn().mockReturnValue(undefined);
|
||||
|
||||
const exception = new BadRequestException('Test error');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Test error',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle exception with string response', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new BadRequestException('Simple string error');
|
||||
|
||||
filter.catch(exception, mockHost);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Simple string error',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate unique request IDs', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception1 = new BadRequestException('Error 1');
|
||||
const exception2 = new BadRequestException('Error 2');
|
||||
|
||||
filter.catch(exception1, mockHost);
|
||||
const firstCall = mockResponse.json.mock.calls[0][0];
|
||||
|
||||
mockResponse.json.mockClear();
|
||||
filter.catch(exception2, mockHost);
|
||||
const secondCall = mockResponse.json.mock.calls[0][0];
|
||||
|
||||
expect(firstCall.request_id).toBeDefined();
|
||||
expect(secondCall.request_id).toBeDefined();
|
||||
expect(firstCall.request_id).not.toBe(secondCall.request_id);
|
||||
});
|
||||
|
||||
it('should include timestamp in response', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
const exception = new BadRequestException('Test error');
|
||||
|
||||
const beforeTime = new Date().toISOString();
|
||||
filter.catch(exception, mockHost);
|
||||
const afterTime = new Date().toISOString();
|
||||
|
||||
const response = mockResponse.json.mock.calls[0][0];
|
||||
expect(response.timestamp).toBeDefined();
|
||||
expect(response.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(response.timestamp >= beforeTime).toBe(true);
|
||||
expect(response.timestamp <= afterTime).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle different HTTP status codes', () => {
|
||||
const { mockHost, mockResponse } = createMockArgumentsHost();
|
||||
|
||||
// 创建一个继承自HttpException的异常,模拟429状态码
|
||||
class TooManyRequestsException extends HttpException {
|
||||
constructor(message: string) {
|
||||
super(message, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
const tooManyRequestsException = new TooManyRequestsException('Too many requests');
|
||||
|
||||
filter.catch(tooManyRequestsException, mockHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.TOO_MANY_REQUESTS);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error_code: 'TOO_MANY_REQUESTS',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* AdminOperationLogController 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试管理员操作日志控制器的所有HTTP端点
|
||||
* - 验证请求参数处理和响应格式
|
||||
* - 测试权限验证和异常处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP层测试,不涉及业务逻辑实现
|
||||
* - Mock业务服务,专注控制器逻辑
|
||||
* - 验证请求响应的正确性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 功能新增 - 创建AdminOperationLogController单元测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-09
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminOperationLogController } from './admin_operation_log.controller';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||
|
||||
describe('AdminOperationLogController', () => {
|
||||
let controller: AdminOperationLogController;
|
||||
let logService: jest.Mocked<AdminOperationLogService>;
|
||||
|
||||
const mockLogService = {
|
||||
queryLogs: jest.fn(),
|
||||
getLogById: jest.fn(),
|
||||
getStatistics: jest.fn(),
|
||||
getSensitiveOperations: jest.fn(),
|
||||
getAdminOperationHistory: jest.fn(),
|
||||
cleanupExpiredLogs: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminOperationLogController],
|
||||
providers: [
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useValue: mockLogService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AdminOperationLogController>(AdminOperationLogController);
|
||||
logService = module.get(AdminOperationLogService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOperationLogs', () => {
|
||||
it('should query logs with default parameters', async () => {
|
||||
const mockLogs = [
|
||||
{ id: 'log1', operation_type: 'CREATE' },
|
||||
{ id: 'log2', operation_type: 'UPDATE' },
|
||||
] as AdminOperationLog[];
|
||||
|
||||
logService.queryLogs.mockResolvedValue({ logs: mockLogs, total: 2 });
|
||||
|
||||
const result = await controller.getOperationLogs(50, 0);
|
||||
|
||||
expect(logService.queryLogs).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
offset: 0
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockLogs);
|
||||
expect(result.data.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should query logs with custom parameters', async () => {
|
||||
const mockLogs = [] as AdminOperationLog[];
|
||||
|
||||
logService.queryLogs.mockResolvedValue({ logs: mockLogs, total: 0 });
|
||||
|
||||
const result = await controller.getOperationLogs(
|
||||
20,
|
||||
10,
|
||||
'admin1',
|
||||
'CREATE',
|
||||
'users',
|
||||
'SUCCESS',
|
||||
'2026-01-01',
|
||||
'2026-01-31',
|
||||
'true'
|
||||
);
|
||||
|
||||
expect(logService.queryLogs).toHaveBeenCalledWith({
|
||||
adminUserId: 'admin1',
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
operationResult: 'SUCCESS',
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2026-01-31'),
|
||||
isSensitive: true,
|
||||
limit: 20,
|
||||
offset: 10
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid date parameters', async () => {
|
||||
await expect(controller.getOperationLogs(
|
||||
50,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'invalid',
|
||||
'invalid'
|
||||
)).rejects.toThrow('日期格式无效,请使用ISO格式');
|
||||
});
|
||||
|
||||
it('should handle service error', async () => {
|
||||
logService.queryLogs.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(controller.getOperationLogs(50, 0)).rejects.toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationLogById', () => {
|
||||
it('should get log by id successfully', async () => {
|
||||
const mockLog = {
|
||||
id: 'log1',
|
||||
operation_type: 'CREATE',
|
||||
target_type: 'users'
|
||||
} as AdminOperationLog;
|
||||
|
||||
logService.getLogById.mockResolvedValue(mockLog);
|
||||
|
||||
const result = await controller.getOperationLogById('log1');
|
||||
|
||||
expect(logService.getLogById).toHaveBeenCalledWith('log1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockLog);
|
||||
});
|
||||
|
||||
it('should handle log not found', async () => {
|
||||
logService.getLogById.mockResolvedValue(null);
|
||||
|
||||
await expect(controller.getOperationLogById('nonexistent')).rejects.toThrow('操作日志不存在');
|
||||
});
|
||||
|
||||
it('should handle service error', async () => {
|
||||
logService.getLogById.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(controller.getOperationLogById('log1')).rejects.toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationStatistics', () => {
|
||||
it('should get statistics successfully', async () => {
|
||||
const mockStats = {
|
||||
totalOperations: 100,
|
||||
successfulOperations: 80,
|
||||
failedOperations: 20,
|
||||
operationsByType: { CREATE: 50, UPDATE: 30, DELETE: 20 },
|
||||
operationsByTarget: { users: 60, profiles: 40 },
|
||||
operationsByAdmin: { admin1: 60, admin2: 40 },
|
||||
averageDuration: 150.5,
|
||||
sensitiveOperations: 10,
|
||||
uniqueAdmins: 5
|
||||
};
|
||||
|
||||
logService.getStatistics.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await controller.getOperationStatistics();
|
||||
|
||||
expect(logService.getStatistics).toHaveBeenCalledWith(undefined, undefined);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockStats);
|
||||
});
|
||||
|
||||
it('should get statistics with date range', async () => {
|
||||
const mockStats = {
|
||||
totalOperations: 50,
|
||||
successfulOperations: 40,
|
||||
failedOperations: 10,
|
||||
operationsByType: {},
|
||||
operationsByTarget: {},
|
||||
operationsByAdmin: {},
|
||||
averageDuration: 100,
|
||||
sensitiveOperations: 5,
|
||||
uniqueAdmins: 3
|
||||
};
|
||||
|
||||
logService.getStatistics.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await controller.getOperationStatistics('2026-01-01', '2026-01-31');
|
||||
|
||||
expect(logService.getStatistics).toHaveBeenCalledWith(
|
||||
new Date('2026-01-01'),
|
||||
new Date('2026-01-31')
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid dates', async () => {
|
||||
await expect(controller.getOperationStatistics('invalid', 'invalid')).rejects.toThrow('日期格式无效,请使用ISO格式');
|
||||
});
|
||||
|
||||
it('should handle service error', async () => {
|
||||
logService.getStatistics.mockRejectedValue(new Error('Statistics error'));
|
||||
|
||||
await expect(controller.getOperationStatistics()).rejects.toThrow('Statistics error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSensitiveOperations', () => {
|
||||
it('should get sensitive operations successfully', async () => {
|
||||
const mockLogs = [
|
||||
{ id: 'log1', is_sensitive: true }
|
||||
] as AdminOperationLog[];
|
||||
|
||||
logService.getSensitiveOperations.mockResolvedValue({ logs: mockLogs, total: 1 });
|
||||
|
||||
const result = await controller.getSensitiveOperations(50, 0);
|
||||
|
||||
expect(logService.getSensitiveOperations).toHaveBeenCalledWith(50, 0);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual(mockLogs);
|
||||
expect(result.data.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should get sensitive operations with pagination', async () => {
|
||||
logService.getSensitiveOperations.mockResolvedValue({ logs: [], total: 0 });
|
||||
|
||||
const result = await controller.getSensitiveOperations(20, 10);
|
||||
|
||||
expect(logService.getSensitiveOperations).toHaveBeenCalledWith(20, 10);
|
||||
});
|
||||
|
||||
it('should handle service error', async () => {
|
||||
logService.getSensitiveOperations.mockRejectedValue(new Error('Query error'));
|
||||
|
||||
await expect(controller.getSensitiveOperations(50, 0)).rejects.toThrow('Query error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredLogs', () => {
|
||||
it('should cleanup logs successfully', async () => {
|
||||
logService.cleanupExpiredLogs.mockResolvedValue(25);
|
||||
|
||||
const result = await controller.cleanupExpiredLogs(90);
|
||||
|
||||
expect(logService.cleanupExpiredLogs).toHaveBeenCalledWith(90);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.deleted_count).toBe(25);
|
||||
});
|
||||
|
||||
it('should cleanup logs with custom retention days', async () => {
|
||||
logService.cleanupExpiredLogs.mockResolvedValue(10);
|
||||
|
||||
const result = await controller.cleanupExpiredLogs(30);
|
||||
|
||||
expect(logService.cleanupExpiredLogs).toHaveBeenCalledWith(30);
|
||||
expect(result.data.deleted_count).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle invalid retention days', async () => {
|
||||
await expect(controller.cleanupExpiredLogs(5)).rejects.toThrow('保留天数必须在7-365天之间');
|
||||
});
|
||||
|
||||
it('should handle service error', async () => {
|
||||
logService.cleanupExpiredLogs.mockRejectedValue(new Error('Cleanup error'));
|
||||
|
||||
await expect(controller.cleanupExpiredLogs(90)).rejects.toThrow('Cleanup error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@
|
||||
*/
|
||||
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
import { OPERATION_TYPES, OPERATION_RESULTS } from './admin_constants';
|
||||
|
||||
@Entity('admin_operation_logs')
|
||||
@Index(['admin_user_id', 'created_at'])
|
||||
@@ -42,7 +41,7 @@ export class AdminOperationLog {
|
||||
admin_username: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, comment: '操作类型 (CREATE/UPDATE/DELETE/QUERY/BATCH)' })
|
||||
operation_type: keyof typeof OPERATION_TYPES;
|
||||
operation_type: 'CREATE' | 'UPDATE' | 'DELETE' | 'QUERY' | 'BATCH';
|
||||
|
||||
@Column({ type: 'varchar', length: 100, comment: '目标资源类型 (users/user_profiles/zulip_accounts)' })
|
||||
target_type: string;
|
||||
@@ -66,7 +65,7 @@ export class AdminOperationLog {
|
||||
after_data?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, comment: '操作结果 (SUCCESS/FAILED)' })
|
||||
operation_result: keyof typeof OPERATION_RESULTS;
|
||||
operation_result: 'SUCCESS' | 'FAILED';
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '错误信息' })
|
||||
error_message?: string;
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
/**
|
||||
* AdminOperationLogInterceptor 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试管理员操作日志拦截器的所有功能
|
||||
* - 验证操作拦截和日志记录的正确性
|
||||
* - 测试成功和失败场景的处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 拦截器逻辑测试,不涉及具体业务
|
||||
* - Mock日志服务,专注拦截器功能
|
||||
* - 验证日志记录的完整性和准确性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 功能新增 - 创建AdminOperationLogInterceptor单元测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-09
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { LOG_ADMIN_OPERATION_KEY, LogAdminOperationOptions } from './log_admin_operation.decorator';
|
||||
import { OPERATION_RESULTS } from './admin_constants';
|
||||
|
||||
describe('AdminOperationLogInterceptor', () => {
|
||||
let interceptor: AdminOperationLogInterceptor;
|
||||
let logService: jest.Mocked<AdminOperationLogService>;
|
||||
let reflector: jest.Mocked<Reflector>;
|
||||
|
||||
const mockLogService = {
|
||||
createLog: jest.fn(),
|
||||
};
|
||||
|
||||
const mockReflector = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AdminOperationLogInterceptor,
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useValue: mockLogService,
|
||||
},
|
||||
{
|
||||
provide: Reflector,
|
||||
useValue: mockReflector,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
interceptor = module.get<AdminOperationLogInterceptor>(AdminOperationLogInterceptor);
|
||||
logService = module.get(AdminOperationLogService);
|
||||
reflector = module.get(Reflector);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (requestData: any = {}) => {
|
||||
const mockRequest = {
|
||||
method: 'POST',
|
||||
url: '/admin/users',
|
||||
route: { path: '/admin/users' },
|
||||
params: { id: '1' },
|
||||
query: { limit: '10' },
|
||||
body: { username: 'testuser' },
|
||||
headers: { 'user-agent': 'test-agent' },
|
||||
user: { id: 'admin1', username: 'admin' },
|
||||
ip: '127.0.0.1',
|
||||
...requestData,
|
||||
};
|
||||
|
||||
const mockResponse = {};
|
||||
|
||||
const mockContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
getResponse: () => mockResponse,
|
||||
}),
|
||||
getHandler: () => ({}),
|
||||
} as ExecutionContext;
|
||||
|
||||
return { mockContext, mockRequest, mockResponse };
|
||||
};
|
||||
|
||||
const createMockCallHandler = (responseData: any = { success: true }) => {
|
||||
return {
|
||||
handle: () => of(responseData),
|
||||
} as CallHandler;
|
||||
};
|
||||
|
||||
describe('intercept', () => {
|
||||
it('should pass through when no log options configured', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
reflector.get.mockReturnValue(undefined);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: (result) => {
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(logService.createLog).not.toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should log successful operation', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const mockHandler = createMockCallHandler({ success: true, data: { id: '1' } });
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
description: 'Create user',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: (result) => {
|
||||
expect(result).toEqual({ success: true, data: { id: '1' } });
|
||||
|
||||
// 验证日志记录调用
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adminUserId: 'admin1',
|
||||
adminUsername: 'admin',
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
operationDescription: 'Create user',
|
||||
httpMethodPath: 'POST /admin/users',
|
||||
operationResult: OPERATION_RESULTS.SUCCESS,
|
||||
targetId: '1',
|
||||
requestParams: expect.objectContaining({
|
||||
params: { id: '1' },
|
||||
query: { limit: '10' },
|
||||
body: { username: 'testuser' },
|
||||
}),
|
||||
afterData: { success: true, data: { id: '1' } },
|
||||
clientIp: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should log failed operation', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const error = new Error('Operation failed');
|
||||
const mockHandler = {
|
||||
handle: () => throwError(() => error),
|
||||
} as CallHandler;
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'UPDATE',
|
||||
targetType: 'users',
|
||||
description: 'Update user',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
error: (err) => {
|
||||
expect(err).toBe(error);
|
||||
|
||||
// 验证错误日志记录调用
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adminUserId: 'admin1',
|
||||
adminUsername: 'admin',
|
||||
operationType: 'UPDATE',
|
||||
targetType: 'users',
|
||||
operationDescription: 'Update user',
|
||||
operationResult: OPERATION_RESULTS.FAILED,
|
||||
errorMessage: 'Operation failed',
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing admin user', (done) => {
|
||||
const { mockContext } = createMockExecutionContext({ user: undefined });
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'QUERY',
|
||||
targetType: 'users',
|
||||
description: 'Query users',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adminUserId: 'unknown',
|
||||
adminUsername: 'unknown',
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle sensitive operations', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'DELETE',
|
||||
targetType: 'users',
|
||||
description: 'Delete user',
|
||||
isSensitive: true,
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSensitive: true,
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable request params capture when configured', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'QUERY',
|
||||
targetType: 'users',
|
||||
description: 'Query users',
|
||||
captureRequestParams: false,
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestParams: undefined,
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable after data capture when configured', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const mockHandler = createMockCallHandler({ data: 'sensitive' });
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'QUERY',
|
||||
targetType: 'users',
|
||||
description: 'Query users',
|
||||
captureAfterData: false,
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
afterData: undefined,
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract affected records from response', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const responseData = {
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ id: '1' }, { id: '2' }, { id: '3' }],
|
||||
total: 3,
|
||||
},
|
||||
};
|
||||
const mockHandler = createMockCallHandler(responseData);
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'QUERY',
|
||||
targetType: 'users',
|
||||
description: 'Query users',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
affectedRecords: 3, // Should extract from items array length
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle log service errors gracefully', (done) => {
|
||||
const { mockContext } = createMockExecutionContext();
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
description: 'Create user',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockRejectedValue(new Error('Log service error'));
|
||||
|
||||
// 即使日志记录失败,原始操作也应该成功
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: (result) => {
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(logService.createLog).toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract target ID from different sources', (done) => {
|
||||
const { mockContext } = createMockExecutionContext({
|
||||
params: {},
|
||||
body: { id: 'body-id' },
|
||||
query: { id: 'query-id' },
|
||||
});
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'UPDATE',
|
||||
targetType: 'users',
|
||||
description: 'Update user',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'body-id', // Should prefer body over query
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing route information', (done) => {
|
||||
const { mockContext } = createMockExecutionContext({
|
||||
route: undefined,
|
||||
url: '/admin/custom-endpoint',
|
||||
});
|
||||
const mockHandler = createMockCallHandler();
|
||||
|
||||
const logOptions: LogAdminOperationOptions = {
|
||||
operationType: 'QUERY',
|
||||
targetType: 'custom',
|
||||
description: 'Custom operation',
|
||||
};
|
||||
|
||||
reflector.get.mockReturnValue(logOptions);
|
||||
logService.createLog.mockResolvedValue({} as any);
|
||||
|
||||
interceptor.intercept(mockContext, mockHandler).subscribe({
|
||||
next: () => {
|
||||
expect(logService.createLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethodPath: 'POST /admin/custom-endpoint',
|
||||
})
|
||||
);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import { Observable, throwError } from 'rxjs';
|
||||
import { tap, catchError } from 'rxjs/operators';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { LOG_ADMIN_OPERATION_KEY, LogAdminOperationOptions } from './log_admin_operation.decorator';
|
||||
import { SENSITIVE_FIELDS, OPERATION_RESULTS } from './admin_constants';
|
||||
import { SENSITIVE_FIELDS } from './admin_constants';
|
||||
import { extractClientIp, generateRequestId, sanitizeRequestBody } from './admin_utils';
|
||||
|
||||
@Injectable()
|
||||
@@ -96,7 +96,7 @@ export class AdminOperationLogInterceptor implements NestInterceptor {
|
||||
targetId,
|
||||
beforeData,
|
||||
afterData: logOptions.captureAfterData !== false ? responseData : undefined,
|
||||
operationResult: OPERATION_RESULTS.SUCCESS,
|
||||
operationResult: 'SUCCESS',
|
||||
durationMs: Date.now() - startTime,
|
||||
affectedRecords: this.extractAffectedRecords(responseData),
|
||||
});
|
||||
@@ -114,7 +114,7 @@ export class AdminOperationLogInterceptor implements NestInterceptor {
|
||||
requestParams,
|
||||
targetId,
|
||||
beforeData,
|
||||
operationResult: OPERATION_RESULTS.FAILED,
|
||||
operationResult: 'FAILED',
|
||||
errorMessage: error.message || String(error),
|
||||
errorCode: error.code || error.status || 'UNKNOWN_ERROR',
|
||||
durationMs: Date.now() - startTime,
|
||||
@@ -139,7 +139,7 @@ export class AdminOperationLogInterceptor implements NestInterceptor {
|
||||
targetId?: string;
|
||||
beforeData?: any;
|
||||
afterData?: any;
|
||||
operationResult: keyof typeof OPERATION_RESULTS;
|
||||
operationResult: 'SUCCESS' | 'FAILED';
|
||||
errorMessage?: string;
|
||||
errorCode?: string;
|
||||
durationMs: number;
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* AdminOperationLogService 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试管理员操作日志服务的所有方法
|
||||
* - 验证日志记录和查询的正确性
|
||||
* - 测试统计功能和清理功能
|
||||
*
|
||||
* 职责分离:
|
||||
* - 业务逻辑测试,不涉及HTTP层
|
||||
* - Mock数据库操作,专注服务逻辑
|
||||
* - 验证日志处理的正确性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 功能新增 - 创建AdminOperationLogService单元测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-09
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminOperationLogService, CreateLogParams, LogQueryParams } from './admin_operation_log.service';
|
||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||
|
||||
describe('AdminOperationLogService', () => {
|
||||
let service: AdminOperationLogService;
|
||||
let repository: jest.Mocked<Repository<AdminOperationLog>>;
|
||||
|
||||
const mockRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findAndCount: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
offset: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn(),
|
||||
getCount: jest.fn(),
|
||||
clone: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn(),
|
||||
getRawOne: jest.fn(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AdminOperationLogService,
|
||||
{
|
||||
provide: getRepositoryToken(AdminOperationLog),
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AdminOperationLogService>(AdminOperationLogService);
|
||||
repository = module.get(getRepositoryToken(AdminOperationLog));
|
||||
|
||||
// Setup default mock behavior
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createLog', () => {
|
||||
it('should create log successfully', async () => {
|
||||
const logParams: CreateLogParams = {
|
||||
adminUserId: 'admin1',
|
||||
adminUsername: 'admin',
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
targetId: '1',
|
||||
operationDescription: 'Create user',
|
||||
httpMethodPath: 'POST /admin/users',
|
||||
operationResult: 'SUCCESS',
|
||||
durationMs: 100,
|
||||
requestId: 'req_123',
|
||||
};
|
||||
|
||||
const mockLog = {
|
||||
id: 'log1',
|
||||
admin_user_id: logParams.adminUserId,
|
||||
admin_username: logParams.adminUsername,
|
||||
operation_type: logParams.operationType,
|
||||
target_type: logParams.targetType,
|
||||
target_id: logParams.targetId,
|
||||
operation_description: logParams.operationDescription,
|
||||
http_method_path: logParams.httpMethodPath,
|
||||
operation_result: logParams.operationResult,
|
||||
duration_ms: logParams.durationMs,
|
||||
request_id: logParams.requestId,
|
||||
is_sensitive: false,
|
||||
affected_records: 0,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
} as AdminOperationLog;
|
||||
|
||||
mockRepository.create.mockReturnValue(mockLog);
|
||||
mockRepository.save.mockResolvedValue(mockLog);
|
||||
|
||||
const result = await service.createLog(logParams);
|
||||
|
||||
expect(mockRepository.create).toHaveBeenCalledWith({
|
||||
admin_user_id: logParams.adminUserId,
|
||||
admin_username: logParams.adminUsername,
|
||||
operation_type: logParams.operationType,
|
||||
target_type: logParams.targetType,
|
||||
target_id: logParams.targetId,
|
||||
operation_description: logParams.operationDescription,
|
||||
http_method_path: logParams.httpMethodPath,
|
||||
request_params: logParams.requestParams,
|
||||
before_data: logParams.beforeData,
|
||||
after_data: logParams.afterData,
|
||||
operation_result: logParams.operationResult,
|
||||
error_message: logParams.errorMessage,
|
||||
error_code: logParams.errorCode,
|
||||
duration_ms: logParams.durationMs,
|
||||
client_ip: logParams.clientIp,
|
||||
user_agent: logParams.userAgent,
|
||||
request_id: logParams.requestId,
|
||||
context: logParams.context,
|
||||
is_sensitive: false,
|
||||
affected_records: 0,
|
||||
batch_id: logParams.batchId,
|
||||
});
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(mockLog);
|
||||
expect(result).toEqual(mockLog);
|
||||
});
|
||||
|
||||
it('should handle creation error', async () => {
|
||||
const logParams: CreateLogParams = {
|
||||
adminUserId: 'admin1',
|
||||
adminUsername: 'admin',
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
operationDescription: 'Create user',
|
||||
httpMethodPath: 'POST /admin/users',
|
||||
operationResult: 'SUCCESS',
|
||||
durationMs: 100,
|
||||
requestId: 'req_123',
|
||||
};
|
||||
|
||||
mockRepository.create.mockReturnValue({} as AdminOperationLog);
|
||||
mockRepository.save.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(service.createLog(logParams)).rejects.toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryLogs', () => {
|
||||
it('should query logs successfully', async () => {
|
||||
const queryParams: LogQueryParams = {
|
||||
adminUserId: 'admin1',
|
||||
operationType: 'CREATE',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
const mockLogs = [
|
||||
{ id: 'log1', admin_user_id: 'admin1' },
|
||||
{ id: 'log2', admin_user_id: 'admin1' },
|
||||
] as AdminOperationLog[];
|
||||
|
||||
mockQueryBuilder.getManyAndCount.mockResolvedValue([mockLogs, 2]);
|
||||
|
||||
const result = await service.queryLogs(queryParams);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('log.admin_user_id = :adminUserId', { adminUserId: 'admin1' });
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('log.operation_type = :operationType', { operationType: 'CREATE' });
|
||||
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('log.created_at', 'DESC');
|
||||
expect(mockQueryBuilder.limit).toHaveBeenCalledWith(10);
|
||||
expect(mockQueryBuilder.offset).toHaveBeenCalledWith(0);
|
||||
|
||||
expect(result.logs).toEqual(mockLogs);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should query logs with date range', async () => {
|
||||
const startDate = new Date('2026-01-01');
|
||||
const endDate = new Date('2026-01-31');
|
||||
const queryParams: LogQueryParams = {
|
||||
startDate,
|
||||
endDate,
|
||||
isSensitive: true,
|
||||
};
|
||||
|
||||
const mockLogs = [] as AdminOperationLog[];
|
||||
mockQueryBuilder.getManyAndCount.mockResolvedValue([mockLogs, 0]);
|
||||
|
||||
const result = await service.queryLogs(queryParams);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('log.created_at BETWEEN :startDate AND :endDate', {
|
||||
startDate,
|
||||
endDate
|
||||
});
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('log.is_sensitive = :isSensitive', { isSensitive: true });
|
||||
});
|
||||
|
||||
it('should handle query error', async () => {
|
||||
const queryParams: LogQueryParams = {};
|
||||
|
||||
mockQueryBuilder.getManyAndCount.mockRejectedValue(new Error('Query error'));
|
||||
|
||||
await expect(service.queryLogs(queryParams)).rejects.toThrow('Query error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogById', () => {
|
||||
it('should get log by id successfully', async () => {
|
||||
const mockLog = { id: 'log1', admin_user_id: 'admin1' } as AdminOperationLog;
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(mockLog);
|
||||
|
||||
const result = await service.getLogById('log1');
|
||||
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 'log1' } });
|
||||
expect(result).toEqual(mockLog);
|
||||
});
|
||||
|
||||
it('should return null when log not found', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getLogById('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle get error', async () => {
|
||||
mockRepository.findOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(service.getLogById('log1')).rejects.toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get statistics successfully', async () => {
|
||||
// Mock basic statistics
|
||||
mockQueryBuilder.getCount
|
||||
.mockResolvedValueOnce(100) // total
|
||||
.mockResolvedValueOnce(80) // successful
|
||||
.mockResolvedValueOnce(10); // sensitive
|
||||
|
||||
// Mock operation type statistics
|
||||
mockQueryBuilder.getRawMany.mockResolvedValueOnce([
|
||||
{ type: 'CREATE', count: '50' },
|
||||
{ type: 'UPDATE', count: '30' },
|
||||
{ type: 'DELETE', count: '20' },
|
||||
]);
|
||||
|
||||
// Mock target type statistics
|
||||
mockQueryBuilder.getRawMany.mockResolvedValueOnce([
|
||||
{ type: 'users', count: '60' },
|
||||
{ type: 'profiles', count: '40' },
|
||||
]);
|
||||
|
||||
// Mock performance statistics
|
||||
mockQueryBuilder.getRawOne
|
||||
.mockResolvedValueOnce({ avgDuration: '150.5' }) // average duration
|
||||
.mockResolvedValueOnce({ uniqueAdmins: '5' }); // unique admins
|
||||
|
||||
const result = await service.getStatistics();
|
||||
|
||||
expect(result.totalOperations).toBe(100);
|
||||
expect(result.successfulOperations).toBe(80);
|
||||
expect(result.failedOperations).toBe(20);
|
||||
expect(result.sensitiveOperations).toBe(10);
|
||||
expect(result.operationsByType).toEqual({
|
||||
CREATE: 50,
|
||||
UPDATE: 30,
|
||||
DELETE: 20,
|
||||
});
|
||||
expect(result.operationsByTarget).toEqual({
|
||||
users: 60,
|
||||
profiles: 40,
|
||||
});
|
||||
expect(result.averageDuration).toBe(150.5);
|
||||
expect(result.uniqueAdmins).toBe(5);
|
||||
});
|
||||
|
||||
it('should get statistics with date range', async () => {
|
||||
const startDate = new Date('2026-01-01');
|
||||
const endDate = new Date('2026-01-31');
|
||||
|
||||
mockQueryBuilder.getCount.mockResolvedValue(50);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue([]);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValue({ avgDuration: '100', uniqueAdmins: '3' });
|
||||
|
||||
const result = await service.getStatistics(startDate, endDate);
|
||||
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('log.created_at BETWEEN :startDate AND :endDate', {
|
||||
startDate,
|
||||
endDate
|
||||
});
|
||||
expect(result.totalOperations).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredLogs', () => {
|
||||
it('should cleanup expired logs successfully', async () => {
|
||||
mockQueryBuilder.execute.mockResolvedValue({ affected: 25 });
|
||||
|
||||
const result = await service.cleanupExpiredLogs(30);
|
||||
|
||||
expect(mockQueryBuilder.delete).toHaveBeenCalled();
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('is_sensitive = :sensitive', { sensitive: false });
|
||||
expect(result).toBe(25);
|
||||
});
|
||||
|
||||
it('should use default retention days', async () => {
|
||||
mockQueryBuilder.execute.mockResolvedValue({ affected: 10 });
|
||||
|
||||
const result = await service.cleanupExpiredLogs();
|
||||
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle cleanup error', async () => {
|
||||
mockQueryBuilder.execute.mockRejectedValue(new Error('Cleanup error'));
|
||||
|
||||
await expect(service.cleanupExpiredLogs(30)).rejects.toThrow('Cleanup error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAdminOperationHistory', () => {
|
||||
it('should get admin operation history successfully', async () => {
|
||||
const mockLogs = [
|
||||
{ id: 'log1', admin_user_id: 'admin1' },
|
||||
{ id: 'log2', admin_user_id: 'admin1' },
|
||||
] as AdminOperationLog[];
|
||||
|
||||
mockRepository.find.mockResolvedValue(mockLogs);
|
||||
|
||||
const result = await service.getAdminOperationHistory('admin1', 10);
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: { admin_user_id: 'admin1' },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 10
|
||||
});
|
||||
expect(result).toEqual(mockLogs);
|
||||
});
|
||||
|
||||
it('should use default limit', async () => {
|
||||
mockRepository.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAdminOperationHistory('admin1');
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith({
|
||||
where: { admin_user_id: 'admin1' },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 20 // DEFAULT_LIMIT
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSensitiveOperations', () => {
|
||||
it('should get sensitive operations successfully', async () => {
|
||||
const mockLogs = [
|
||||
{ id: 'log1', is_sensitive: true },
|
||||
] as AdminOperationLog[];
|
||||
|
||||
mockRepository.findAndCount.mockResolvedValue([mockLogs, 1]);
|
||||
|
||||
const result = await service.getSensitiveOperations(10, 0);
|
||||
|
||||
expect(mockRepository.findAndCount).toHaveBeenCalledWith({
|
||||
where: { is_sensitive: true },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 10,
|
||||
skip: 0
|
||||
});
|
||||
expect(result.logs).toEqual(mockLogs);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should use default pagination', async () => {
|
||||
mockRepository.findAndCount.mockResolvedValue([[], 0]);
|
||||
|
||||
const result = await service.getSensitiveOperations();
|
||||
|
||||
expect(mockRepository.findAndCount).toHaveBeenCalledWith({
|
||||
where: { is_sensitive: true },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 50, // DEFAULT_LIMIT
|
||||
skip: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,6 @@
|
||||
* - 日志管理:自动清理和归档功能
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 代码质量优化 - 使用常量替代硬编码字符串,提高代码一致性 (修改者: moyin)
|
||||
* - 2026-01-09: 代码质量优化 - 拆分getStatistics长方法为多个私有方法,提高可读性 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 为接口添加注释,完善文档说明 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 添加类注释,完善服务文档说明 (修改者: moyin)
|
||||
@@ -23,16 +21,16 @@
|
||||
* - 2026-01-08: 功能新增 - 创建管理员操作日志服务 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.4.0
|
||||
* @version 1.2.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||
import { LOG_QUERY_LIMITS, USER_QUERY_LIMITS, LOG_RETENTION, OPERATION_TYPES, OPERATION_RESULTS } from './admin_constants';
|
||||
import { LOG_QUERY_LIMITS, USER_QUERY_LIMITS, LOG_RETENTION } from './admin_constants';
|
||||
|
||||
/**
|
||||
* 创建日志参数接口
|
||||
@@ -47,7 +45,7 @@ import { LOG_QUERY_LIMITS, USER_QUERY_LIMITS, LOG_RETENTION, OPERATION_TYPES, OP
|
||||
export interface CreateLogParams {
|
||||
adminUserId: string;
|
||||
adminUsername: string;
|
||||
operationType: keyof typeof OPERATION_TYPES;
|
||||
operationType: 'CREATE' | 'UPDATE' | 'DELETE' | 'QUERY' | 'BATCH';
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
operationDescription: string;
|
||||
@@ -55,7 +53,7 @@ export interface CreateLogParams {
|
||||
requestParams?: Record<string, any>;
|
||||
beforeData?: Record<string, any>;
|
||||
afterData?: Record<string, any>;
|
||||
operationResult: keyof typeof OPERATION_RESULTS;
|
||||
operationResult: 'SUCCESS' | 'FAILED';
|
||||
errorMessage?: string;
|
||||
errorCode?: string;
|
||||
durationMs: number;
|
||||
@@ -106,7 +104,6 @@ export interface LogStatistics {
|
||||
failedOperations: number;
|
||||
operationsByType: Record<string, number>;
|
||||
operationsByTarget: Record<string, number>;
|
||||
operationsByAdmin: Record<string, number>;
|
||||
averageDuration: number;
|
||||
sensitiveOperations: number;
|
||||
uniqueAdmins: number;
|
||||
@@ -304,133 +301,6 @@ export class AdminOperationLogService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础统计数据
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 基础统计数据
|
||||
*/
|
||||
private async getBasicStatistics(queryBuilder: any): Promise<{
|
||||
totalOperations: number;
|
||||
successfulOperations: number;
|
||||
failedOperations: number;
|
||||
sensitiveOperations: number;
|
||||
}> {
|
||||
const totalOperations = await queryBuilder.getCount();
|
||||
|
||||
const successfulOperations = await queryBuilder
|
||||
.clone()
|
||||
.andWhere('log.operation_result = :result', { result: OPERATION_RESULTS.SUCCESS })
|
||||
.getCount();
|
||||
|
||||
const failedOperations = totalOperations - successfulOperations;
|
||||
|
||||
const sensitiveOperations = await queryBuilder
|
||||
.clone()
|
||||
.andWhere('log.is_sensitive = :sensitive', { sensitive: true })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalOperations,
|
||||
successfulOperations,
|
||||
failedOperations,
|
||||
sensitiveOperations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作类型统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 操作类型统计
|
||||
*/
|
||||
private async getOperationTypeStatistics(queryBuilder: any): Promise<Record<string, number>> {
|
||||
const operationTypeStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.operation_type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.operation_type')
|
||||
.getRawMany();
|
||||
|
||||
return operationTypeStats.reduce((acc, stat) => {
|
||||
acc[stat.type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标类型统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 目标类型统计
|
||||
*/
|
||||
private async getTargetTypeStatistics(queryBuilder: any): Promise<Record<string, number>> {
|
||||
const targetTypeStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.target_type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.target_type')
|
||||
.getRawMany();
|
||||
|
||||
return targetTypeStats.reduce((acc, stat) => {
|
||||
acc[stat.type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 管理员统计
|
||||
*/
|
||||
private async getAdminStatistics(queryBuilder: any): Promise<Record<string, number>> {
|
||||
const adminStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.admin_user_id', 'admin')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.admin_user_id')
|
||||
.getRawMany();
|
||||
|
||||
if (!adminStats || !Array.isArray(adminStats)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return adminStats.reduce((acc, stat) => {
|
||||
acc[stat.admin] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 性能统计
|
||||
*/
|
||||
private async getPerformanceStatistics(queryBuilder: any): Promise<{
|
||||
averageDuration: number;
|
||||
uniqueAdmins: number;
|
||||
}> {
|
||||
// 平均耗时
|
||||
const avgDurationResult = await queryBuilder
|
||||
.clone()
|
||||
.select('AVG(log.duration_ms)', 'avgDuration')
|
||||
.getRawOne();
|
||||
|
||||
const averageDuration = parseFloat(avgDurationResult?.avgDuration || '0');
|
||||
|
||||
// 唯一管理员数量
|
||||
const uniqueAdminsResult = await queryBuilder
|
||||
.clone()
|
||||
.select('COUNT(DISTINCT log.admin_user_id)', 'uniqueAdmins')
|
||||
.getRawOne();
|
||||
|
||||
const uniqueAdmins = parseInt(uniqueAdminsResult?.uniqueAdmins || '0');
|
||||
|
||||
return { averageDuration, uniqueAdmins };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作统计信息
|
||||
*
|
||||
@@ -449,19 +319,72 @@ export class AdminOperationLogService {
|
||||
});
|
||||
}
|
||||
|
||||
// 获取各类统计数据
|
||||
const basicStats = await this.getBasicStatistics(queryBuilder);
|
||||
const operationsByType = await this.getOperationTypeStatistics(queryBuilder);
|
||||
const operationsByTarget = await this.getTargetTypeStatistics(queryBuilder);
|
||||
const operationsByAdmin = await this.getAdminStatistics(queryBuilder);
|
||||
const performanceStats = await this.getPerformanceStatistics(queryBuilder);
|
||||
// 基础统计
|
||||
const totalOperations = await queryBuilder.getCount();
|
||||
|
||||
const successfulOperations = await queryBuilder
|
||||
.clone()
|
||||
.andWhere('log.operation_result = :result', { result: 'SUCCESS' })
|
||||
.getCount();
|
||||
|
||||
const failedOperations = totalOperations - successfulOperations;
|
||||
|
||||
const sensitiveOperations = await queryBuilder
|
||||
.clone()
|
||||
.andWhere('log.is_sensitive = :sensitive', { sensitive: true })
|
||||
.getCount();
|
||||
|
||||
// 按操作类型统计
|
||||
const operationTypeStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.operation_type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.operation_type')
|
||||
.getRawMany();
|
||||
|
||||
const operationsByType = operationTypeStats.reduce((acc, stat) => {
|
||||
acc[stat.type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// 按目标类型统计
|
||||
const targetTypeStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.target_type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.target_type')
|
||||
.getRawMany();
|
||||
|
||||
const operationsByTarget = targetTypeStats.reduce((acc, stat) => {
|
||||
acc[stat.type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// 平均耗时
|
||||
const avgDurationResult = await queryBuilder
|
||||
.clone()
|
||||
.select('AVG(log.duration_ms)', 'avgDuration')
|
||||
.getRawOne();
|
||||
|
||||
const averageDuration = parseFloat(avgDurationResult?.avgDuration || '0');
|
||||
|
||||
// 唯一管理员数量
|
||||
const uniqueAdminsResult = await queryBuilder
|
||||
.clone()
|
||||
.select('COUNT(DISTINCT log.admin_user_id)', 'uniqueAdmins')
|
||||
.getRawOne();
|
||||
|
||||
const uniqueAdmins = parseInt(uniqueAdminsResult?.uniqueAdmins || '0');
|
||||
|
||||
const statistics: LogStatistics = {
|
||||
...basicStats,
|
||||
totalOperations,
|
||||
successfulOperations,
|
||||
failedOperations,
|
||||
operationsByType,
|
||||
operationsByTarget,
|
||||
operationsByAdmin,
|
||||
...performanceStats
|
||||
averageDuration,
|
||||
sensitiveOperations,
|
||||
uniqueAdmins
|
||||
};
|
||||
|
||||
this.logger.log('操作统计获取成功', statistics);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
|
||||
@@ -51,21 +52,26 @@ export const DEFAULT_PROPERTY_CONFIG: PropertyTestConfig = {
|
||||
* 属性测试生成器
|
||||
*/
|
||||
export class PropertyTestGenerators {
|
||||
private static setupFaker(seed?: number) {
|
||||
if (seed) {
|
||||
faker.seed(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机用户数据
|
||||
*/
|
||||
static generateUser(seed?: number) {
|
||||
const random = seed ? Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) : Math.random();
|
||||
const id = Math.floor(random * 1000000);
|
||||
this.setupFaker(seed);
|
||||
return {
|
||||
username: `testuser${id}`,
|
||||
nickname: `Test User ${id}`,
|
||||
email: `test${id}@example.com`,
|
||||
phone: `138${String(id).padStart(8, '0').substring(0, 8)}`,
|
||||
role: Math.floor(random * 10),
|
||||
status: ['ACTIVE', 'INACTIVE', 'SUSPENDED'][Math.floor(random * 3)] as any,
|
||||
avatar_url: `https://example.com/avatar${id}.jpg`,
|
||||
github_id: `github${id}`
|
||||
username: faker.internet.username(),
|
||||
nickname: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
phone: faker.phone.number(),
|
||||
role: faker.number.int({ min: 0, max: 9 }),
|
||||
status: faker.helpers.enumValue(UserStatus),
|
||||
avatar_url: faker.image.avatar(),
|
||||
github_id: faker.string.alphanumeric(10)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,22 +79,21 @@ export class PropertyTestGenerators {
|
||||
* 生成随机用户档案数据
|
||||
*/
|
||||
static generateUserProfile(seed?: number) {
|
||||
const random = seed ? Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) : Math.random();
|
||||
const id = Math.floor(random * 1000000);
|
||||
this.setupFaker(seed);
|
||||
return {
|
||||
user_id: String(id),
|
||||
bio: `This is a test bio for user ${id}`,
|
||||
resume_content: `Test resume content for user ${id}. This is a sample resume.`,
|
||||
tags: JSON.stringify(['developer', 'tester']),
|
||||
user_id: faker.string.numeric(10),
|
||||
bio: faker.lorem.paragraph(),
|
||||
resume_content: faker.lorem.paragraphs(3),
|
||||
tags: JSON.stringify(faker.helpers.arrayElements(['developer', 'designer', 'manager'], { min: 1, max: 3 })),
|
||||
social_links: JSON.stringify({
|
||||
github: `https://github.com/user${id}`,
|
||||
linkedin: `https://linkedin.com/in/user${id}`
|
||||
github: faker.internet.url(),
|
||||
linkedin: faker.internet.url()
|
||||
}),
|
||||
skin_id: `skin${id}`,
|
||||
current_map: ['plaza', 'forest', 'beach', 'mountain'][Math.floor(random * 4)],
|
||||
pos_x: random * 1000,
|
||||
pos_y: random * 1000,
|
||||
status: Math.floor(random * 3)
|
||||
skin_id: faker.string.alphanumeric(8),
|
||||
current_map: faker.helpers.arrayElement(['plaza', 'forest', 'beach', 'mountain']),
|
||||
pos_x: faker.number.float({ min: 0, max: 1000 }),
|
||||
pos_y: faker.number.float({ min: 0, max: 1000 }),
|
||||
status: faker.number.int({ min: 0, max: 2 })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,16 +101,14 @@ export class PropertyTestGenerators {
|
||||
* 生成随机Zulip账号数据
|
||||
*/
|
||||
static generateZulipAccount(seed?: number) {
|
||||
const random = seed ? Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) : Math.random();
|
||||
const id = Math.floor(random * 1000000);
|
||||
const statuses = ['active', 'inactive', 'suspended', 'error'] as const;
|
||||
this.setupFaker(seed);
|
||||
return {
|
||||
gameUserId: String(id),
|
||||
zulipUserId: Math.floor(random * 999999) + 1,
|
||||
zulipEmail: `zulip${id}@example.com`,
|
||||
zulipFullName: `Zulip User ${id}`,
|
||||
zulipApiKeyEncrypted: `encrypted_key_${id}`,
|
||||
status: statuses[Math.floor(random * 4)]
|
||||
gameUserId: faker.string.numeric(10),
|
||||
zulipUserId: faker.number.int({ min: 1, max: 999999 }),
|
||||
zulipEmail: faker.internet.email(),
|
||||
zulipFullName: faker.person.fullName(),
|
||||
zulipApiKeyEncrypted: faker.string.alphanumeric(32),
|
||||
status: faker.helpers.arrayElement(['active', 'inactive', 'suspended', 'error'] as const)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,10 +116,10 @@ export class PropertyTestGenerators {
|
||||
* 生成随机分页参数
|
||||
*/
|
||||
static generatePaginationParams(seed?: number) {
|
||||
const random = seed ? Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) : Math.random();
|
||||
this.setupFaker(seed);
|
||||
return {
|
||||
limit: Math.floor(random * 100) + 1,
|
||||
offset: Math.floor(random * 1000)
|
||||
limit: faker.number.int({ min: 1, max: 100 }),
|
||||
offset: faker.number.int({ min: 0, max: 1000 })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
@@ -40,160 +40,8 @@ describe('Property Test: API响应格式一致性', () => {
|
||||
let app: INestApplication;
|
||||
let module: TestingModule;
|
||||
let controller: AdminDatabaseController;
|
||||
let mockDatabaseService: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockDatabaseService = {
|
||||
getUserList: jest.fn().mockImplementation((limit, offset) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: limit || 20,
|
||||
offset: offset || 0,
|
||||
has_more: false
|
||||
},
|
||||
message: '获取用户列表成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getUserById: jest.fn().mockImplementation((id) => {
|
||||
const user = PropertyTestGenerators.generateUser();
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { ...user, id: id.toString() },
|
||||
message: '获取用户详情成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
createUser: jest.fn().mockImplementation((userData) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { ...userData, id: '1' },
|
||||
message: '创建用户成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
updateUser: jest.fn().mockImplementation((id, updateData) => {
|
||||
const user = PropertyTestGenerators.generateUser();
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { ...user, ...updateData, id: id.toString() },
|
||||
message: '更新用户成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
deleteUser: jest.fn().mockImplementation((id) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { deleted: true, id: id.toString() },
|
||||
message: '删除用户成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
searchUsers: jest.fn().mockImplementation((searchTerm, limit) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: limit || 20,
|
||||
offset: 0,
|
||||
has_more: false
|
||||
},
|
||||
message: '搜索用户成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getUserProfileList: jest.fn().mockImplementation((limit, offset) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: limit || 20,
|
||||
offset: offset || 0,
|
||||
has_more: false
|
||||
},
|
||||
message: '获取用户档案列表成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getUserProfileById: jest.fn().mockImplementation((id) => {
|
||||
const profile = PropertyTestGenerators.generateUserProfile();
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { ...profile, id: id.toString() },
|
||||
message: '获取用户档案详情成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getUserProfilesByMap: jest.fn().mockImplementation((map, limit, offset) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: limit || 20,
|
||||
offset: offset || 0,
|
||||
has_more: false
|
||||
},
|
||||
message: '按地图获取用户档案成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getZulipAccountList: jest.fn().mockImplementation((limit, offset) => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: limit || 20,
|
||||
offset: offset || 0,
|
||||
has_more: false
|
||||
},
|
||||
message: '获取Zulip账号列表成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getZulipAccountById: jest.fn().mockImplementation((id) => {
|
||||
const account = PropertyTestGenerators.generateZulipAccount();
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { ...account, id: id.toString() },
|
||||
message: '获取Zulip账号详情成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
}),
|
||||
getZulipAccountStatistics: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: {
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
suspended: 0,
|
||||
error: 0,
|
||||
total: 0
|
||||
},
|
||||
message: '获取Zulip账号统计成功',
|
||||
timestamp: new Date().toISOString(),
|
||||
request_id: 'test_' + Date.now()
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -203,10 +51,7 @@ describe('Property Test: API响应格式一致性', () => {
|
||||
],
|
||||
controllers: [AdminDatabaseController],
|
||||
providers: [
|
||||
{
|
||||
provide: DatabaseManagementService,
|
||||
useValue: mockDatabaseService
|
||||
},
|
||||
DatabaseManagementService,
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useValue: {
|
||||
@@ -224,6 +69,71 @@ describe('Property Test: API响应格式一致性', () => {
|
||||
useValue: {
|
||||
intercept: jest.fn().mockImplementation((context, next) => next.handle())
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockImplementation(() => {
|
||||
const user = PropertyTestGenerators.generateUser();
|
||||
return Promise.resolve({ ...user, id: BigInt(1) });
|
||||
}),
|
||||
create: jest.fn().mockImplementation((userData) => {
|
||||
return Promise.resolve({ ...userData, id: BigInt(1) });
|
||||
}),
|
||||
update: jest.fn().mockImplementation((id, updateData) => {
|
||||
const user = PropertyTestGenerators.generateUser();
|
||||
return Promise.resolve({ ...user, ...updateData, id });
|
||||
}),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: 'IUserProfilesService',
|
||||
useValue: {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockImplementation(() => {
|
||||
const profile = PropertyTestGenerators.generateUserProfile();
|
||||
return Promise.resolve({ ...profile, id: BigInt(1) });
|
||||
}),
|
||||
create: jest.fn().mockImplementation((profileData) => {
|
||||
return Promise.resolve({ ...profileData, id: BigInt(1) });
|
||||
}),
|
||||
update: jest.fn().mockImplementation((id, updateData) => {
|
||||
const profile = PropertyTestGenerators.generateUserProfile();
|
||||
return Promise.resolve({ ...profile, ...updateData, id });
|
||||
}),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
findByMap: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0)
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: {
|
||||
findMany: jest.fn().mockResolvedValue({ accounts: [] }),
|
||||
findById: jest.fn().mockImplementation(() => {
|
||||
const account = PropertyTestGenerators.generateZulipAccount();
|
||||
return Promise.resolve({ ...account, id: '1' });
|
||||
}),
|
||||
create: jest.fn().mockImplementation((accountData) => {
|
||||
return Promise.resolve({ ...accountData, id: '1' });
|
||||
}),
|
||||
update: jest.fn().mockImplementation((id, updateData) => {
|
||||
const account = PropertyTestGenerators.generateZulipAccount();
|
||||
return Promise.resolve({ ...account, ...updateData, id });
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
getStatusStatistics: jest.fn().mockResolvedValue({
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
suspended: 0,
|
||||
error: 0,
|
||||
total: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
/**
|
||||
* DatabaseManagementService 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试数据库管理服务的所有方法
|
||||
* - 验证CRUD操作的正确性
|
||||
* - 测试异常处理和边界情况
|
||||
*
|
||||
* 职责分离:
|
||||
* - 业务逻辑测试,不涉及HTTP层
|
||||
* - Mock数据库服务,专注业务服务逻辑
|
||||
* - 验证数据处理和格式化的正确性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 功能新增 - 创建DatabaseManagementService单元测试 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-09
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { UserProfilesService } from '../../core/db/user_profiles/user_profiles.service';
|
||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { UserProfiles } from '../../core/db/user_profiles/user_profiles.entity';
|
||||
|
||||
describe('DatabaseManagementService', () => {
|
||||
let service: DatabaseManagementService;
|
||||
let usersService: jest.Mocked<UsersService>;
|
||||
let userProfilesService: jest.Mocked<UserProfilesService>;
|
||||
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
|
||||
|
||||
const mockUsersService = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
search: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUserProfilesService = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findByMap: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountsService = {
|
||||
findMany: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
getStatusStatistics: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
batchUpdateStatus: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseManagementService,
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
provide: 'IUserProfilesService',
|
||||
useValue: mockUserProfilesService,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: mockZulipAccountsService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DatabaseManagementService>(DatabaseManagementService);
|
||||
usersService = module.get('UsersService');
|
||||
userProfilesService = module.get('IUserProfilesService');
|
||||
zulipAccountsService = module.get('ZulipAccountsService');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getUserList', () => {
|
||||
it('should return user list successfully', async () => {
|
||||
const mockUsers = [
|
||||
{ id: BigInt(1), username: 'user1', email: 'user1@test.com' },
|
||||
{ id: BigInt(2), username: 'user2', email: 'user2@test.com' }
|
||||
] as Users[];
|
||||
|
||||
usersService.findAll.mockResolvedValue(mockUsers);
|
||||
usersService.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.getUserList(20, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.total).toBe(2);
|
||||
expect(result.message).toBe('用户列表获取成功');
|
||||
});
|
||||
|
||||
it('should handle database error', async () => {
|
||||
usersService.findAll.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.getUserList(20, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toEqual([]);
|
||||
expect(result.message).toContain('失败,返回空列表');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserById', () => {
|
||||
it('should return user by id successfully', async () => {
|
||||
const mockUser = { id: BigInt(1), username: 'user1', email: 'user1@test.com' } as Users;
|
||||
|
||||
usersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.getUserById(BigInt(1));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe('1');
|
||||
expect(result.message).toBe('用户详情获取成功');
|
||||
});
|
||||
|
||||
it('should handle user not found', async () => {
|
||||
usersService.findOne.mockRejectedValue(new NotFoundException('User not found'));
|
||||
|
||||
const result = await service.getUserById(BigInt(999));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('RESOURCE_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchUsers', () => {
|
||||
it('should search users successfully', async () => {
|
||||
const mockUsers = [
|
||||
{ id: BigInt(1), username: 'admin', email: 'admin@test.com' }
|
||||
] as Users[];
|
||||
|
||||
usersService.search.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await service.searchUsers('admin', 20);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.message).toBe('用户搜索成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create user successfully', async () => {
|
||||
const userData = { username: 'newuser', email: 'new@test.com', nickname: 'New User' };
|
||||
const mockUser = { id: BigInt(1), ...userData } as Users;
|
||||
|
||||
usersService.create.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.createUser(userData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.username).toBe('newuser');
|
||||
expect(result.message).toBe('用户创建成功');
|
||||
});
|
||||
|
||||
it('should handle creation conflict', async () => {
|
||||
const userData = { username: 'existing', email: 'existing@test.com', nickname: 'Existing' };
|
||||
|
||||
usersService.create.mockRejectedValue(new ConflictException('Username already exists'));
|
||||
|
||||
const result = await service.createUser(userData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('RESOURCE_CONFLICT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user successfully', async () => {
|
||||
const updateData = { nickname: 'Updated User' };
|
||||
const mockUser = { id: BigInt(1), username: 'user1', nickname: 'Updated User' } as Users;
|
||||
|
||||
usersService.update.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.updateUser(BigInt(1), updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.nickname).toBe('Updated User');
|
||||
expect(result.message).toBe('用户更新成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('should delete user successfully', async () => {
|
||||
usersService.remove.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.deleteUser(BigInt(1));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.deleted).toBe(true);
|
||||
expect(result.message).toBe('用户删除成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserProfileList', () => {
|
||||
it('should return user profile list successfully', async () => {
|
||||
const mockProfiles = [
|
||||
{ id: BigInt(1), user_id: BigInt(1), bio: 'Test bio' }
|
||||
] as UserProfiles[];
|
||||
|
||||
userProfilesService.findAll.mockResolvedValue(mockProfiles);
|
||||
userProfilesService.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.getUserProfileList(20, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.message).toBe('用户档案列表获取成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserProfileById', () => {
|
||||
it('should return user profile by id successfully', async () => {
|
||||
const mockProfile = { id: BigInt(1), user_id: BigInt(1), bio: 'Test bio' } as UserProfiles;
|
||||
|
||||
userProfilesService.findOne.mockResolvedValue(mockProfile);
|
||||
|
||||
const result = await service.getUserProfileById(BigInt(1));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe('1');
|
||||
expect(result.message).toBe('用户档案详情获取成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserProfilesByMap', () => {
|
||||
it('should return user profiles by map successfully', async () => {
|
||||
const mockProfiles = [
|
||||
{ id: BigInt(1), user_id: BigInt(1), current_map: 'plaza' }
|
||||
] as UserProfiles[];
|
||||
|
||||
userProfilesService.findByMap.mockResolvedValue(mockProfiles);
|
||||
|
||||
const result = await service.getUserProfilesByMap('plaza', 20, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.message).toContain('plaza');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserProfile', () => {
|
||||
it('should create user profile successfully', async () => {
|
||||
const profileData = {
|
||||
user_id: '1',
|
||||
bio: 'Test bio',
|
||||
resume_content: 'Test resume',
|
||||
tags: '["tag1"]',
|
||||
social_links: '{"github":"test"}',
|
||||
skin_id: '1',
|
||||
current_map: 'plaza',
|
||||
pos_x: 100,
|
||||
pos_y: 200,
|
||||
status: 1
|
||||
};
|
||||
|
||||
const mockProfile = { id: BigInt(1), user_id: BigInt(1), bio: 'Test bio' } as UserProfiles;
|
||||
|
||||
userProfilesService.create.mockResolvedValue(mockProfile);
|
||||
|
||||
const result = await service.createUserProfile(profileData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('用户档案创建成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserProfile', () => {
|
||||
it('should update user profile successfully', async () => {
|
||||
const updateData = { bio: 'Updated bio' };
|
||||
const mockProfile = { id: BigInt(1), user_id: BigInt(1), bio: 'Updated bio' } as UserProfiles;
|
||||
|
||||
userProfilesService.update.mockResolvedValue(mockProfile);
|
||||
|
||||
const result = await service.updateUserProfile(BigInt(1), updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('用户档案更新成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserProfile', () => {
|
||||
it('should delete user profile successfully', async () => {
|
||||
userProfilesService.remove.mockResolvedValue({ affected: 1, message: 'Deleted successfully' });
|
||||
|
||||
const result = await service.deleteUserProfile(BigInt(1));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.deleted).toBe(true);
|
||||
expect(result.message).toBe('用户档案删除成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipAccountList', () => {
|
||||
it('should return zulip account list successfully', async () => {
|
||||
const mockAccounts = {
|
||||
accounts: [{
|
||||
id: '1',
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test User',
|
||||
status: 'active' as const,
|
||||
retryCount: 0,
|
||||
createdAt: '2026-01-09T00:00:00.000Z',
|
||||
updatedAt: '2026-01-09T00:00:00.000Z'
|
||||
}],
|
||||
total: 1,
|
||||
count: 1
|
||||
};
|
||||
|
||||
zulipAccountsService.findMany.mockResolvedValue(mockAccounts);
|
||||
|
||||
const result = await service.getZulipAccountList(20, 0);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.message).toBe('Zulip账号关联列表获取成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipAccountById', () => {
|
||||
it('should return zulip account by id successfully', async () => {
|
||||
const mockAccount = {
|
||||
id: '1',
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test User',
|
||||
status: 'active' as const,
|
||||
retryCount: 0,
|
||||
createdAt: '2026-01-09T00:00:00.000Z',
|
||||
updatedAt: '2026-01-09T00:00:00.000Z'
|
||||
};
|
||||
|
||||
zulipAccountsService.findById.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.getZulipAccountById('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe('1');
|
||||
expect(result.message).toBe('Zulip账号关联详情获取成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZulipAccountStatistics', () => {
|
||||
it('should return zulip account statistics successfully', async () => {
|
||||
const mockStats = {
|
||||
active: 10,
|
||||
inactive: 5,
|
||||
suspended: 2,
|
||||
error: 1,
|
||||
total: 18
|
||||
};
|
||||
|
||||
zulipAccountsService.getStatusStatistics.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await service.getZulipAccountStatistics();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockStats);
|
||||
expect(result.message).toBe('Zulip账号关联统计获取成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createZulipAccount', () => {
|
||||
it('should create zulip account successfully', async () => {
|
||||
const accountData = {
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test User',
|
||||
zulipApiKeyEncrypted: 'encrypted_key'
|
||||
};
|
||||
|
||||
const mockAccount = {
|
||||
id: '1',
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Test User',
|
||||
zulipApiKeyEncrypted: 'encrypted_key',
|
||||
status: 'active' as const,
|
||||
retryCount: 0,
|
||||
createdAt: '2026-01-09T00:00:00.000Z',
|
||||
updatedAt: '2026-01-09T00:00:00.000Z'
|
||||
};
|
||||
|
||||
zulipAccountsService.create.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.createZulipAccount(accountData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Zulip账号关联创建成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateZulipAccount', () => {
|
||||
it('should update zulip account successfully', async () => {
|
||||
const updateData = { zulipFullName: 'Updated Name' };
|
||||
const mockAccount = {
|
||||
id: '1',
|
||||
gameUserId: '1',
|
||||
zulipUserId: 123,
|
||||
zulipEmail: 'test@zulip.com',
|
||||
zulipFullName: 'Updated Name',
|
||||
status: 'active' as const,
|
||||
retryCount: 0,
|
||||
createdAt: '2026-01-09T00:00:00.000Z',
|
||||
updatedAt: '2026-01-09T00:00:00.000Z'
|
||||
};
|
||||
|
||||
zulipAccountsService.update.mockResolvedValue(mockAccount);
|
||||
|
||||
const result = await service.updateZulipAccount('1', updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Zulip账号关联更新成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteZulipAccount', () => {
|
||||
it('should delete zulip account successfully', async () => {
|
||||
zulipAccountsService.delete.mockResolvedValue(true);
|
||||
|
||||
const result = await service.deleteZulipAccount('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.deleted).toBe(true);
|
||||
expect(result.message).toBe('Zulip账号关联删除成功');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchUpdateZulipAccountStatus', () => {
|
||||
it('should batch update zulip account status successfully', async () => {
|
||||
const ids = ['1', '2', '3'];
|
||||
const status = 'active';
|
||||
const reason = 'Batch activation';
|
||||
|
||||
zulipAccountsService.batchUpdateStatus.mockResolvedValue({
|
||||
success: true,
|
||||
updatedCount: 3
|
||||
});
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(ids, status, reason);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.success_count).toBe(3);
|
||||
expect(result.data.failed_count).toBe(0);
|
||||
expect(result.message).toContain('成功:3,失败:0');
|
||||
});
|
||||
|
||||
it('should handle partial batch update failure', async () => {
|
||||
const ids = ['1', '2', '3'];
|
||||
const status = 'active';
|
||||
|
||||
zulipAccountsService.batchUpdateStatus.mockResolvedValue({
|
||||
success: true,
|
||||
updatedCount: 2
|
||||
});
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(ids, status);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.success_count).toBe(2);
|
||||
expect(result.data.failed_count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,10 +19,6 @@
|
||||
* - ZulipAccountsService: Zulip账号关联管理
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: Bug修复 - 修复类型错误,正确处理skin_id类型转换和Zulip账号查询参数 (修改者: moyin)
|
||||
* - 2026-01-09: 功能实现 - 实现所有TODO项,完成UserProfiles和ZulipAccounts的CRUD操作 (修改者: moyin)
|
||||
* - 2026-01-09: 代码质量优化 - 替换any类型为具体的DTO类型,提高类型安全性 (修改者: moyin)
|
||||
* - 2026-01-09: 代码质量优化 - 统一使用admin_utils中的响应创建函数,消除重复代码 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 完善方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||||
* - 2026-01-08: 代码规范优化 - 将魔法数字20提取为常量DEFAULT_PAGE_SIZE (修改者: moyin)
|
||||
@@ -30,26 +26,16 @@
|
||||
* - 2026-01-08: 功能新增 - 创建数据库管理服务,支持管理员数据库操作 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.6.0
|
||||
* @version 1.2.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
* @lastModified 2026-01-08
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { UserProfilesService } from '../../core/db/user_profiles/user_profiles.service';
|
||||
import { UserProfiles } from '../../core/db/user_profiles/user_profiles.entity';
|
||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||
import { ZulipAccountResponseDto } from '../../core/db/zulip_accounts/zulip_accounts.dto';
|
||||
import { getCurrentTimestamp, UserFormatter, OperationMonitor, createSuccessResponse, createErrorResponse, createListResponse } from './admin_utils';
|
||||
import {
|
||||
AdminCreateUserDto,
|
||||
AdminUpdateUserDto,
|
||||
AdminCreateUserProfileDto,
|
||||
AdminUpdateUserProfileDto,
|
||||
AdminCreateZulipAccountDto,
|
||||
AdminUpdateZulipAccountDto
|
||||
} from './admin_database.dto';
|
||||
import { generateRequestId, getCurrentTimestamp, UserFormatter, OperationMonitor } from './admin_utils';
|
||||
|
||||
/**
|
||||
* 常量定义
|
||||
@@ -92,8 +78,6 @@ export class DatabaseManagementService {
|
||||
|
||||
constructor(
|
||||
@Inject('UsersService') private readonly usersService: UsersService,
|
||||
@Inject('IUserProfilesService') private readonly userProfilesService: UserProfilesService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: any,
|
||||
) {
|
||||
this.logger.log('DatabaseManagementService初始化完成');
|
||||
}
|
||||
@@ -112,6 +96,81 @@ export class DatabaseManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准的成功响应
|
||||
*
|
||||
* 功能描述:
|
||||
* 创建符合管理员API标准格式的成功响应对象
|
||||
*
|
||||
* @param data 响应数据
|
||||
* @param message 响应消息
|
||||
* @returns 标准格式的成功响应
|
||||
*/
|
||||
private createSuccessResponse<T>(data: T, message: string): AdminApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准的错误响应
|
||||
*
|
||||
* 功能描述:
|
||||
* 创建符合管理员API标准格式的错误响应对象
|
||||
*
|
||||
* @param message 错误消息
|
||||
* @param errorCode 错误码
|
||||
* @returns 标准格式的错误响应
|
||||
*/
|
||||
private createErrorResponse(message: string, errorCode?: string): AdminApiResponse {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
error_code: errorCode,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准的列表响应
|
||||
*
|
||||
* 功能描述:
|
||||
* 创建符合管理员API标准格式的列表响应对象,包含分页信息
|
||||
*
|
||||
* @param items 列表项
|
||||
* @param total 总数
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @param message 响应消息
|
||||
* @returns 标准格式的列表响应
|
||||
*/
|
||||
private createListResponse<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
limit: number,
|
||||
offset: number,
|
||||
message: string
|
||||
): AdminListResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
has_more: offset + items.length < total
|
||||
},
|
||||
message,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理服务异常
|
||||
*
|
||||
@@ -128,18 +187,18 @@ export class DatabaseManagementService {
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
return createErrorResponse(error.message, 'RESOURCE_NOT_FOUND');
|
||||
return this.createErrorResponse(error.message, 'RESOURCE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (error instanceof ConflictException) {
|
||||
return createErrorResponse(error.message, 'RESOURCE_CONFLICT');
|
||||
return this.createErrorResponse(error.message, 'RESOURCE_CONFLICT');
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestException) {
|
||||
return createErrorResponse(error.message, 'INVALID_REQUEST');
|
||||
return this.createErrorResponse(error.message, 'INVALID_REQUEST');
|
||||
}
|
||||
|
||||
return createErrorResponse(`${operation}失败,请稍后重试`, 'INTERNAL_ERROR');
|
||||
return this.createErrorResponse(`${operation}失败,请稍后重试`, 'INTERNAL_ERROR');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +216,7 @@ export class DatabaseManagementService {
|
||||
context
|
||||
});
|
||||
|
||||
return createListResponse([], 0, context.limit || DEFAULT_PAGE_SIZE, context.offset || 0, `${operation}失败,返回空列表`);
|
||||
return this.createListResponse([], 0, context.limit || DEFAULT_PAGE_SIZE, context.offset || 0, `${operation}失败,返回空列表`);
|
||||
}
|
||||
|
||||
// ==================== 用户管理方法 ====================
|
||||
@@ -197,7 +256,7 @@ export class DatabaseManagementService {
|
||||
const users = await this.usersService.findAll(limit, offset);
|
||||
const total = await this.usersService.count();
|
||||
const formattedUsers = users.map(user => UserFormatter.formatBasicUser(user));
|
||||
return createListResponse(formattedUsers, total, limit, offset, '用户列表获取成功');
|
||||
return this.createListResponse(formattedUsers, total, limit, offset, '用户列表获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '获取用户列表', { limit, offset }));
|
||||
@@ -237,7 +296,7 @@ export class DatabaseManagementService {
|
||||
async () => {
|
||||
const user = await this.usersService.findOne(id);
|
||||
const formattedUser = UserFormatter.formatDetailedUser(user);
|
||||
return createSuccessResponse(formattedUser, '用户详情获取成功');
|
||||
return this.createSuccessResponse(formattedUser, '用户详情获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取用户详情', { userId: id.toString() }));
|
||||
@@ -276,7 +335,7 @@ export class DatabaseManagementService {
|
||||
async () => {
|
||||
const users = await this.usersService.search(keyword, limit);
|
||||
const formattedUsers = users.map(user => UserFormatter.formatBasicUser(user));
|
||||
return createListResponse(formattedUsers, users.length, limit, 0, '用户搜索成功');
|
||||
return this.createListResponse(formattedUsers, users.length, limit, 0, '用户搜索成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '搜索用户', { keyword, limit }));
|
||||
@@ -288,14 +347,14 @@ export class DatabaseManagementService {
|
||||
* @param userData 用户数据
|
||||
* @returns 创建结果响应
|
||||
*/
|
||||
async createUser(userData: AdminCreateUserDto): Promise<AdminApiResponse> {
|
||||
async createUser(userData: any): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'创建用户',
|
||||
{ username: userData.username },
|
||||
async () => {
|
||||
const newUser = await this.usersService.create(userData);
|
||||
const formattedUser = UserFormatter.formatBasicUser(newUser);
|
||||
return createSuccessResponse(formattedUser, '用户创建成功');
|
||||
return this.createSuccessResponse(formattedUser, '用户创建成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '创建用户', { username: userData.username }));
|
||||
@@ -308,14 +367,14 @@ export class DatabaseManagementService {
|
||||
* @param updateData 更新数据
|
||||
* @returns 更新结果响应
|
||||
*/
|
||||
async updateUser(id: bigint, updateData: AdminUpdateUserDto): Promise<AdminApiResponse> {
|
||||
async updateUser(id: bigint, updateData: any): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'更新用户',
|
||||
{ userId: id.toString(), updateFields: Object.keys(updateData) },
|
||||
async () => {
|
||||
const updatedUser = await this.usersService.update(id, updateData);
|
||||
const formattedUser = UserFormatter.formatBasicUser(updatedUser);
|
||||
return createSuccessResponse(formattedUser, '用户更新成功');
|
||||
return this.createSuccessResponse(formattedUser, '用户更新成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '更新用户', { userId: id.toString(), updateData }));
|
||||
@@ -333,7 +392,7 @@ export class DatabaseManagementService {
|
||||
{ userId: id.toString() },
|
||||
async () => {
|
||||
await this.usersService.remove(id);
|
||||
return createSuccessResponse({ deleted: true, id: id.toString() }, '用户删除成功');
|
||||
return this.createSuccessResponse({ deleted: true, id: id.toString() }, '用户删除成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '删除用户', { userId: id.toString() }));
|
||||
@@ -349,17 +408,8 @@ export class DatabaseManagementService {
|
||||
* @returns 用户档案列表响应
|
||||
*/
|
||||
async getUserProfileList(limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取用户档案列表',
|
||||
{ limit, offset },
|
||||
async () => {
|
||||
const profiles = await this.userProfilesService.findAll({ limit, offset });
|
||||
const total = await this.userProfilesService.count();
|
||||
const formattedProfiles = profiles.map(profile => this.formatUserProfile(profile));
|
||||
return createListResponse(formattedProfiles, total, limit, offset, '用户档案列表获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '获取用户档案列表', { limit, offset }));
|
||||
// TODO: 实现用户档案列表查询
|
||||
return this.createListResponse([], 0, limit, offset, '用户档案列表获取成功(暂未实现)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,16 +419,8 @@ export class DatabaseManagementService {
|
||||
* @returns 用户档案详情响应
|
||||
*/
|
||||
async getUserProfileById(id: bigint): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取用户档案详情',
|
||||
{ profileId: id.toString() },
|
||||
async () => {
|
||||
const profile = await this.userProfilesService.findOne(id);
|
||||
const formattedProfile = this.formatUserProfile(profile);
|
||||
return createSuccessResponse(formattedProfile, '用户档案详情获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取用户档案详情', { profileId: id.toString() }));
|
||||
// TODO: 实现用户档案详情查询
|
||||
return this.createErrorResponse('用户档案详情查询暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,17 +432,8 @@ export class DatabaseManagementService {
|
||||
* @returns 用户档案列表响应
|
||||
*/
|
||||
async getUserProfilesByMap(mapId: string, limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'根据地图获取用户档案',
|
||||
{ mapId, limit, offset },
|
||||
async () => {
|
||||
const profiles = await this.userProfilesService.findByMap(mapId, undefined, limit, offset);
|
||||
const total = await this.userProfilesService.count();
|
||||
const formattedProfiles = profiles.map(profile => this.formatUserProfile(profile));
|
||||
return createListResponse(formattedProfiles, total, limit, offset, `地图 ${mapId} 的用户档案列表获取成功`);
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '根据地图获取用户档案', { mapId, limit, offset }));
|
||||
// TODO: 实现按地图查询用户档案
|
||||
return this.createListResponse([], 0, limit, offset, `地图 ${mapId} 的用户档案列表获取成功(暂未实现)`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,30 +442,9 @@ export class DatabaseManagementService {
|
||||
* @param createProfileDto 创建数据
|
||||
* @returns 创建结果响应
|
||||
*/
|
||||
async createUserProfile(createProfileDto: AdminCreateUserProfileDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'创建用户档案',
|
||||
{ userId: createProfileDto.user_id },
|
||||
async () => {
|
||||
const profileData = {
|
||||
user_id: BigInt(createProfileDto.user_id),
|
||||
bio: createProfileDto.bio,
|
||||
resume_content: createProfileDto.resume_content,
|
||||
tags: createProfileDto.tags ? JSON.parse(createProfileDto.tags) : undefined,
|
||||
social_links: createProfileDto.social_links ? JSON.parse(createProfileDto.social_links) : undefined,
|
||||
skin_id: createProfileDto.skin_id ? parseInt(createProfileDto.skin_id) : undefined,
|
||||
current_map: createProfileDto.current_map,
|
||||
pos_x: createProfileDto.pos_x,
|
||||
pos_y: createProfileDto.pos_y,
|
||||
status: createProfileDto.status
|
||||
};
|
||||
|
||||
const newProfile = await this.userProfilesService.create(profileData);
|
||||
const formattedProfile = this.formatUserProfile(newProfile);
|
||||
return createSuccessResponse(formattedProfile, '用户档案创建成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '创建用户档案', { userId: createProfileDto.user_id }));
|
||||
async createUserProfile(createProfileDto: any): Promise<AdminApiResponse> {
|
||||
// TODO: 实现用户档案创建
|
||||
return this.createErrorResponse('用户档案创建暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,48 +454,9 @@ export class DatabaseManagementService {
|
||||
* @param updateProfileDto 更新数据
|
||||
* @returns 更新结果响应
|
||||
*/
|
||||
async updateUserProfile(id: bigint, updateProfileDto: AdminUpdateUserProfileDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'更新用户档案',
|
||||
{ profileId: id.toString(), updateFields: Object.keys(updateProfileDto) },
|
||||
async () => {
|
||||
// 转换AdminUpdateUserProfileDto为UpdateUserProfileDto
|
||||
const updateData: any = {};
|
||||
|
||||
if (updateProfileDto.bio !== undefined) {
|
||||
updateData.bio = updateProfileDto.bio;
|
||||
}
|
||||
if (updateProfileDto.resume_content !== undefined) {
|
||||
updateData.resume_content = updateProfileDto.resume_content;
|
||||
}
|
||||
if (updateProfileDto.tags !== undefined) {
|
||||
updateData.tags = JSON.parse(updateProfileDto.tags);
|
||||
}
|
||||
if (updateProfileDto.social_links !== undefined) {
|
||||
updateData.social_links = JSON.parse(updateProfileDto.social_links);
|
||||
}
|
||||
if (updateProfileDto.skin_id !== undefined) {
|
||||
updateData.skin_id = parseInt(updateProfileDto.skin_id);
|
||||
}
|
||||
if (updateProfileDto.current_map !== undefined) {
|
||||
updateData.current_map = updateProfileDto.current_map;
|
||||
}
|
||||
if (updateProfileDto.pos_x !== undefined) {
|
||||
updateData.pos_x = updateProfileDto.pos_x;
|
||||
}
|
||||
if (updateProfileDto.pos_y !== undefined) {
|
||||
updateData.pos_y = updateProfileDto.pos_y;
|
||||
}
|
||||
if (updateProfileDto.status !== undefined) {
|
||||
updateData.status = updateProfileDto.status;
|
||||
}
|
||||
|
||||
const updatedProfile = await this.userProfilesService.update(id, updateData);
|
||||
const formattedProfile = this.formatUserProfile(updatedProfile);
|
||||
return createSuccessResponse(formattedProfile, '用户档案更新成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '更新用户档案', { profileId: id.toString(), updateData: updateProfileDto }));
|
||||
async updateUserProfile(id: bigint, updateProfileDto: any): Promise<AdminApiResponse> {
|
||||
// TODO: 实现用户档案更新
|
||||
return this.createErrorResponse('用户档案更新暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,15 +466,8 @@ export class DatabaseManagementService {
|
||||
* @returns 删除结果响应
|
||||
*/
|
||||
async deleteUserProfile(id: bigint): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'删除用户档案',
|
||||
{ profileId: id.toString() },
|
||||
async () => {
|
||||
const result = await this.userProfilesService.remove(id);
|
||||
return createSuccessResponse({ deleted: true, id: id.toString(), affected: result.affected }, '用户档案删除成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '删除用户档案', { profileId: id.toString() }));
|
||||
// TODO: 实现用户档案删除
|
||||
return this.createErrorResponse('用户档案删除暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
// ==================== Zulip账号关联管理方法 ====================
|
||||
@@ -514,24 +480,8 @@ export class DatabaseManagementService {
|
||||
* @returns Zulip账号关联列表响应
|
||||
*/
|
||||
async getZulipAccountList(limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取Zulip账号关联列表',
|
||||
{ limit, offset },
|
||||
async () => {
|
||||
// ZulipAccountsService的findMany方法目前不支持分页参数
|
||||
// 先获取所有数据,然后手动分页
|
||||
const result = await this.zulipAccountsService.findMany({});
|
||||
|
||||
// 手动实现分页
|
||||
const startIndex = offset;
|
||||
const endIndex = offset + limit;
|
||||
const paginatedAccounts = result.accounts.slice(startIndex, endIndex);
|
||||
|
||||
const formattedAccounts = paginatedAccounts.map(account => this.formatZulipAccount(account));
|
||||
return createListResponse(formattedAccounts, result.total, limit, offset, 'Zulip账号关联列表获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '获取Zulip账号关联列表', { limit, offset }));
|
||||
// TODO: 实现Zulip账号关联列表查询
|
||||
return this.createListResponse([], 0, limit, offset, 'Zulip账号关联列表获取成功(暂未实现)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,16 +491,8 @@ export class DatabaseManagementService {
|
||||
* @returns Zulip账号关联详情响应
|
||||
*/
|
||||
async getZulipAccountById(id: string): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取Zulip账号关联详情',
|
||||
{ accountId: id },
|
||||
async () => {
|
||||
const account = await this.zulipAccountsService.findById(id, true);
|
||||
const formattedAccount = this.formatZulipAccount(account);
|
||||
return createSuccessResponse(formattedAccount, 'Zulip账号关联详情获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取Zulip账号关联详情', { accountId: id }));
|
||||
// TODO: 实现Zulip账号关联详情查询
|
||||
return this.createErrorResponse('Zulip账号关联详情查询暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -559,15 +501,13 @@ export class DatabaseManagementService {
|
||||
* @returns 统计信息响应
|
||||
*/
|
||||
async getZulipAccountStatistics(): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取Zulip账号关联统计',
|
||||
{},
|
||||
async () => {
|
||||
const stats = await this.zulipAccountsService.getStatusStatistics();
|
||||
return createSuccessResponse(stats, 'Zulip账号关联统计获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取Zulip账号关联统计', {}));
|
||||
// TODO: 实现Zulip账号关联统计
|
||||
return this.createSuccessResponse({
|
||||
total: 0,
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
error: 0
|
||||
}, 'Zulip账号关联统计获取成功(暂未实现)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,17 +516,9 @@ export class DatabaseManagementService {
|
||||
* @param createAccountDto 创建数据
|
||||
* @returns 创建结果响应
|
||||
*/
|
||||
async createZulipAccount(createAccountDto: AdminCreateZulipAccountDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'创建Zulip账号关联',
|
||||
{ gameUserId: createAccountDto.gameUserId },
|
||||
async () => {
|
||||
const newAccount = await this.zulipAccountsService.create(createAccountDto);
|
||||
const formattedAccount = this.formatZulipAccount(newAccount);
|
||||
return createSuccessResponse(formattedAccount, 'Zulip账号关联创建成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '创建Zulip账号关联', { gameUserId: createAccountDto.gameUserId }));
|
||||
async createZulipAccount(createAccountDto: any): Promise<AdminApiResponse> {
|
||||
// TODO: 实现Zulip账号关联创建
|
||||
return this.createErrorResponse('Zulip账号关联创建暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,17 +528,9 @@ export class DatabaseManagementService {
|
||||
* @param updateAccountDto 更新数据
|
||||
* @returns 更新结果响应
|
||||
*/
|
||||
async updateZulipAccount(id: string, updateAccountDto: AdminUpdateZulipAccountDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'更新Zulip账号关联',
|
||||
{ accountId: id, updateFields: Object.keys(updateAccountDto) },
|
||||
async () => {
|
||||
const updatedAccount = await this.zulipAccountsService.update(id, updateAccountDto);
|
||||
const formattedAccount = this.formatZulipAccount(updatedAccount);
|
||||
return createSuccessResponse(formattedAccount, 'Zulip账号关联更新成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '更新Zulip账号关联', { accountId: id, updateData: updateAccountDto }));
|
||||
async updateZulipAccount(id: string, updateAccountDto: any): Promise<AdminApiResponse> {
|
||||
// TODO: 实现Zulip账号关联更新
|
||||
return this.createErrorResponse('Zulip账号关联更新暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,15 +540,8 @@ export class DatabaseManagementService {
|
||||
* @returns 删除结果响应
|
||||
*/
|
||||
async deleteZulipAccount(id: string): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'删除Zulip账号关联',
|
||||
{ accountId: id },
|
||||
async () => {
|
||||
const result = await this.zulipAccountsService.delete(id);
|
||||
return createSuccessResponse({ deleted: result, id }, 'Zulip账号关联删除成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '删除Zulip账号关联', { accountId: id }));
|
||||
// TODO: 实现Zulip账号关联删除
|
||||
return this.createErrorResponse('Zulip账号关联删除暂未实现', 'NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,67 +553,12 @@ export class DatabaseManagementService {
|
||||
* @returns 批量更新结果响应
|
||||
*/
|
||||
async batchUpdateZulipAccountStatus(ids: string[], status: string, reason?: string): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'批量更新Zulip账号状态',
|
||||
{ count: ids.length, status, reason },
|
||||
async () => {
|
||||
const result = await this.zulipAccountsService.batchUpdateStatus(ids, status as any);
|
||||
return createSuccessResponse({
|
||||
success_count: result.updatedCount,
|
||||
failed_count: ids.length - result.updatedCount,
|
||||
total_count: ids.length,
|
||||
reason
|
||||
}, `Zulip账号关联批量状态更新完成,成功:${result.updatedCount},失败:${ids.length - result.updatedCount}`);
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '批量更新Zulip账号状态', { count: ids.length, status, reason }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户档案信息
|
||||
*
|
||||
* @param profile 用户档案实体
|
||||
* @returns 格式化的用户档案信息
|
||||
*/
|
||||
private formatUserProfile(profile: UserProfiles) {
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
user_id: profile.user_id.toString(),
|
||||
bio: profile.bio,
|
||||
resume_content: profile.resume_content,
|
||||
tags: profile.tags,
|
||||
social_links: profile.social_links,
|
||||
skin_id: profile.skin_id,
|
||||
current_map: profile.current_map,
|
||||
pos_x: profile.pos_x,
|
||||
pos_y: profile.pos_y,
|
||||
status: profile.status,
|
||||
last_login_at: profile.last_login_at,
|
||||
last_position_update: profile.last_position_update
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化Zulip账号关联信息
|
||||
*
|
||||
* @param account Zulip账号关联实体
|
||||
* @returns 格式化的Zulip账号关联信息
|
||||
*/
|
||||
private formatZulipAccount(account: ZulipAccountResponseDto) {
|
||||
return {
|
||||
id: account.id,
|
||||
gameUserId: account.gameUserId,
|
||||
zulipUserId: account.zulipUserId,
|
||||
zulipEmail: account.zulipEmail,
|
||||
zulipFullName: account.zulipFullName,
|
||||
status: account.status,
|
||||
lastVerifiedAt: account.lastVerifiedAt,
|
||||
lastSyncedAt: account.lastSyncedAt,
|
||||
errorMessage: account.errorMessage,
|
||||
retryCount: account.retryCount,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
gameUser: account.gameUser
|
||||
};
|
||||
// TODO: 实现Zulip账号关联批量状态更新
|
||||
return this.createSuccessResponse({
|
||||
success_count: 0,
|
||||
failed_count: ids.length,
|
||||
total_count: ids.length,
|
||||
errors: ids.map(id => ({ id, error: '批量更新暂未实现' }))
|
||||
}, 'Zulip账号关联批量状态更新完成(暂未实现)');
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
|
||||
describe('DatabaseManagementService Unit Tests', () => {
|
||||
let service: DatabaseManagementService;
|
||||
@@ -56,7 +56,6 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({ success: true, updatedCount: 0 }),
|
||||
getStatusStatistics: jest.fn()
|
||||
};
|
||||
|
||||
@@ -169,7 +168,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
const mockUser = { id: BigInt(1), username: 'testuser', email: 'test@example.com' };
|
||||
mockUsersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.getUserById(BigInt(1));
|
||||
const result = await service.getUserById('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ ...mockUser, id: '1' });
|
||||
@@ -179,7 +178,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
it('should return error when user not found', async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getUserById(BigInt(999));
|
||||
const result = await service.getUserById('999');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('USER_NOT_FOUND');
|
||||
@@ -187,7 +186,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should handle invalid ID format', async () => {
|
||||
const result = await service.getUserById(BigInt(0)); // 使用有效的 bigint
|
||||
const result = await service.getUserById('invalid');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('INVALID_USER_ID');
|
||||
@@ -196,7 +195,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
it('should handle service errors', async () => {
|
||||
mockUsersService.findOne.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.getUserById(BigInt(1));
|
||||
const result = await service.getUserById('1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('DATABASE_ERROR');
|
||||
@@ -208,7 +207,6 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
const userData = {
|
||||
username: 'newuser',
|
||||
email: 'new@example.com',
|
||||
nickname: 'New User',
|
||||
status: UserStatus.ACTIVE
|
||||
};
|
||||
const createdUser = { ...userData, id: BigInt(1) };
|
||||
@@ -223,7 +221,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should handle duplicate username error', async () => {
|
||||
const userData = { username: 'existing', email: 'test@example.com', nickname: 'Existing User', status: UserStatus.ACTIVE };
|
||||
const userData = { username: 'existing', email: 'test@example.com', status: UserStatus.ACTIVE };
|
||||
mockUsersService.create.mockRejectedValue(new Error('Duplicate key violation'));
|
||||
|
||||
const result = await service.createUser(userData);
|
||||
@@ -233,7 +231,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const invalidData = { username: '', email: 'test@example.com', nickname: 'Test User', status: UserStatus.ACTIVE };
|
||||
const invalidData = { username: '', email: 'test@example.com', status: UserStatus.ACTIVE };
|
||||
|
||||
const result = await service.createUser(invalidData);
|
||||
|
||||
@@ -242,7 +240,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const invalidData = { username: 'test', email: 'invalid-email', nickname: 'Test User', status: UserStatus.ACTIVE };
|
||||
const invalidData = { username: 'test', email: 'invalid-email', status: UserStatus.ACTIVE };
|
||||
|
||||
const result = await service.createUser(invalidData);
|
||||
|
||||
@@ -260,7 +258,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
mockUsersService.findOne.mockResolvedValue(existingUser);
|
||||
mockUsersService.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.updateUser(BigInt(1), updateData);
|
||||
const result = await service.updateUser('1', updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ ...updatedUser, id: '1' });
|
||||
@@ -270,14 +268,14 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
it('should return error when user not found', async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.updateUser(BigInt(999), { nickname: 'New Name' });
|
||||
const result = await service.updateUser('999', { nickname: 'New Name' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should handle empty update data', async () => {
|
||||
const result = await service.updateUser(BigInt(1), {});
|
||||
const result = await service.updateUser('1', {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
@@ -291,7 +289,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
mockUsersService.findOne.mockResolvedValue(existingUser);
|
||||
mockUsersService.remove.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.deleteUser(BigInt(1));
|
||||
const result = await service.deleteUser('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.deleted).toBe(true);
|
||||
@@ -302,7 +300,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
it('should return error when user not found', async () => {
|
||||
mockUsersService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.deleteUser(BigInt(999));
|
||||
const result = await service.deleteUser('999');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('USER_NOT_FOUND');
|
||||
@@ -474,15 +472,17 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
|
||||
describe('batchUpdateZulipAccountStatus', () => {
|
||||
it('should update multiple accounts successfully', async () => {
|
||||
const ids = ['1', '2'];
|
||||
const status = 'active';
|
||||
const reason = 'Test update';
|
||||
const batchData = {
|
||||
ids: ['1', '2'],
|
||||
status: 'active' as const,
|
||||
reason: 'Test update'
|
||||
};
|
||||
|
||||
mockZulipAccountsService.update
|
||||
.mockResolvedValueOnce({ id: '1', status: 'active' })
|
||||
.mockResolvedValueOnce({ id: '2', status: 'active' });
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(ids, status, reason);
|
||||
const result = await service.batchUpdateZulipAccountStatus(batchData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.total).toBe(2);
|
||||
@@ -492,15 +492,17 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
const ids = ['1', '2'];
|
||||
const status = 'active';
|
||||
const reason = 'Test update';
|
||||
const batchData = {
|
||||
ids: ['1', '2'],
|
||||
status: 'active' as const,
|
||||
reason: 'Test update'
|
||||
};
|
||||
|
||||
mockZulipAccountsService.update
|
||||
.mockResolvedValueOnce({ id: '1', status: 'active' })
|
||||
.mockRejectedValueOnce(new Error('Update failed'));
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(ids, status, reason);
|
||||
const result = await service.batchUpdateZulipAccountStatus(batchData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.total).toBe(2);
|
||||
@@ -510,11 +512,13 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should validate batch data', async () => {
|
||||
const ids: string[] = [];
|
||||
const status = 'active';
|
||||
const reason = 'Test';
|
||||
const invalidData = {
|
||||
ids: [],
|
||||
status: 'active' as const,
|
||||
reason: 'Test'
|
||||
};
|
||||
|
||||
const result = await service.batchUpdateZulipAccountStatus(ids, status, reason);
|
||||
const result = await service.batchUpdateZulipAccountStatus(invalidData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error_code).toBe('VALIDATION_ERROR');
|
||||
@@ -541,18 +545,18 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// describe('Health Check', () => {
|
||||
// describe('healthCheck', () => {
|
||||
// it('should return healthy status', async () => {
|
||||
// const result = await service.healthCheck();
|
||||
describe('Health Check', () => {
|
||||
describe('healthCheck', () => {
|
||||
it('should return healthy status', async () => {
|
||||
const result = await service.healthCheck();
|
||||
|
||||
// expect(result.success).toBe(true);
|
||||
// expect(result.data.status).toBe('healthy');
|
||||
// expect(result.data.timestamp).toBeDefined();
|
||||
// expect(result.data.services).toBeDefined();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.status).toBe('healthy');
|
||||
expect(result.data.timestamp).toBeDefined();
|
||||
expect(result.data.services).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle service injection errors', () => {
|
||||
@@ -566,7 +570,7 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
const mockUser = { id: BigInt(123456789012345), username: 'test' };
|
||||
mockUsersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.getUserById(BigInt('123456789012345'));
|
||||
const result = await service.getUserById('123456789012345');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe('123456789012345');
|
||||
@@ -577,9 +581,9 @@ describe('DatabaseManagementService Unit Tests', () => {
|
||||
mockUsersService.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const promises = [
|
||||
service.getUserById(BigInt(1)),
|
||||
service.getUserById(BigInt(1)),
|
||||
service.getUserById(BigInt(1))
|
||||
service.getUserById('1'),
|
||||
service.getUserById('1'),
|
||||
service.getUserById('1')
|
||||
];
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
@@ -72,7 +72,6 @@ describe('Property Test: 错误处理功能', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({ success: true, updatedCount: 0 }),
|
||||
getStatusStatistics: jest.fn()
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
*/
|
||||
|
||||
import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { OPERATION_TYPES } from './admin_constants';
|
||||
|
||||
/**
|
||||
* 管理员操作日志装饰器配置选项
|
||||
@@ -40,7 +39,7 @@ import { OPERATION_TYPES } from './admin_constants';
|
||||
* - 指定操作类型、目标类型和敏感性等属性
|
||||
*/
|
||||
export interface LogAdminOperationOptions {
|
||||
operationType: keyof typeof OPERATION_TYPES;
|
||||
operationType: 'CREATE' | 'UPDATE' | 'DELETE' | 'QUERY' | 'BATCH';
|
||||
targetType: string;
|
||||
description: string;
|
||||
isSensitive?: boolean;
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { AdminOperationLogController } from './admin_operation_log.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { AdminOperationLogController } from '../../controllers/admin_operation_log.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
@@ -175,7 +175,6 @@ describe('Property Test: 操作日志功能', () => {
|
||||
create: jest.fn().mockResolvedValue({ id: '1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: '1' }),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({ success: true, updatedCount: 0 }),
|
||||
getStatusStatistics: jest.fn().mockResolvedValue({
|
||||
active: 0, inactive: 0, suspended: 0, error: 0, total: 0
|
||||
})
|
||||
@@ -341,16 +340,14 @@ describe('Property Test: 操作日志功能', () => {
|
||||
});
|
||||
|
||||
// 查询日志
|
||||
const response = await logController.getOperationLogs(
|
||||
20, // limit
|
||||
0, // offset
|
||||
filters.admin_id,
|
||||
const response = await logController.queryLogs(
|
||||
filters.operation_type,
|
||||
filters.entity_type,
|
||||
undefined, // operation_result
|
||||
undefined, // start_date
|
||||
undefined, // end_date
|
||||
undefined // is_sensitive
|
||||
filters.admin_id,
|
||||
undefined,
|
||||
undefined,
|
||||
'20', // 修复:传递字符串而不是数字
|
||||
0
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
@@ -391,7 +388,7 @@ describe('Property Test: 操作日志功能', () => {
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
const response = await logController.getOperationStatistics();
|
||||
const response = await logController.getStatistics();
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.totalOperations).toBe(operations.length);
|
||||
@@ -495,23 +492,13 @@ describe('Property Test: 操作日志功能', () => {
|
||||
});
|
||||
|
||||
// 查询特定管理员的操作历史
|
||||
const response = await logController.getOperationLogs(
|
||||
50, // limit
|
||||
0, // offset
|
||||
adminId, // adminUserId
|
||||
undefined, // operationType
|
||||
undefined, // targetType
|
||||
undefined, // operationResult
|
||||
undefined, // startDate
|
||||
undefined, // endDate
|
||||
undefined // isSensitive
|
||||
);
|
||||
const response = await logController.getAdminOperationHistory(adminId);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.items).toHaveLength(operations.length);
|
||||
expect(response.data).toHaveLength(operations.length);
|
||||
|
||||
// 验证所有返回的日志都属于指定管理员
|
||||
response.data.items.forEach((log: any) => {
|
||||
response.data.forEach((log: any) => {
|
||||
expect(log.admin_id).toBe(adminId);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -24,13 +24,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
@@ -73,7 +72,6 @@ describe('Property Test: 分页查询功能', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({ success: true, updatedCount: 0 }),
|
||||
getStatusStatistics: jest.fn()
|
||||
};
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
@@ -135,7 +135,6 @@ describe('Property Test: 性能监控功能', () => {
|
||||
create: createPerformanceAwareMock('ZulipAccountsService', 'create', 100),
|
||||
update: createPerformanceAwareMock('ZulipAccountsService', 'update', 80),
|
||||
delete: createPerformanceAwareMock('ZulipAccountsService', 'delete', 50),
|
||||
batchUpdateStatus: createPerformanceAwareMock('ZulipAccountsService', 'batchUpdateStatus', 120),
|
||||
getStatusStatistics: createPerformanceAwareMock('ZulipAccountsService', 'getStatusStatistics', 60)
|
||||
};
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { UserStatus } from '../user_mgmt/user_status.enum';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import { UserStatus } from '../../../../core/db/users/user_status.enum';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { AdminDatabaseController } from '../../controllers/admin_database.controller';
|
||||
import { DatabaseManagementService } from '../../services/database_management.service';
|
||||
import { AdminOperationLogService } from '../../services/admin_operation_log.service';
|
||||
import { AdminOperationLogInterceptor } from '../../admin_operation_log.interceptor';
|
||||
import { AdminDatabaseExceptionFilter } from '../../admin_database_exception.filter';
|
||||
import { AdminGuard } from '../../admin.guard';
|
||||
import {
|
||||
PropertyTestRunner,
|
||||
PropertyTestGenerators,
|
||||
@@ -50,7 +50,6 @@ describe('Property Test: Zulip账号关联管理功能', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
batchUpdateStatus: jest.fn().mockResolvedValue({ success: true, updatedCount: 0 }),
|
||||
getStatusStatistics: jest.fn().mockResolvedValue({
|
||||
active: 0, inactive: 0, suspended: 0, error: 0, total: 0
|
||||
})
|
||||
|
||||
@@ -1,330 +1,223 @@
|
||||
# 认证业务模块 (Auth Business Module)
|
||||
# Auth 用户认证业务模块
|
||||
|
||||
## 架构层级
|
||||
Auth 是应用的核心用户认证业务模块,提供完整的用户登录、注册、密码管理、JWT令牌认证和GitHub OAuth集成功能,支持邮箱验证、验证码登录、安全防护和Zulip账号同步,具备完善的业务流程控制、错误处理和安全审计能力。
|
||||
|
||||
**Business Layer(业务层)**
|
||||
## 用户认证功能
|
||||
|
||||
## 职责定位
|
||||
### login()
|
||||
处理用户登录请求,支持用户名/邮箱/手机号登录,验证用户凭据并生成JWT令牌。
|
||||
|
||||
业务层负责实现核心业务逻辑和流程控制:
|
||||
### register()
|
||||
处理用户注册请求,支持邮箱验证,自动创建Zulip账号并建立关联。
|
||||
|
||||
1. **业务流程**:实现完整的业务流程和规则
|
||||
2. **服务协调**:协调多个核心服务完成业务功能
|
||||
3. **数据转换**:将核心层数据转换为业务数据
|
||||
4. **业务验证**:实现业务规则验证
|
||||
5. **事务管理**:处理跨服务的事务逻辑
|
||||
### githubOAuth()
|
||||
处理GitHub OAuth登录,支持新用户自动注册和现有用户绑定。
|
||||
|
||||
## 模块组成
|
||||
### verificationCodeLogin()
|
||||
支持邮箱或手机号验证码登录,提供无密码登录方式。
|
||||
|
||||
```
|
||||
src/business/auth/
|
||||
├── login.service.ts # 登录业务服务
|
||||
├── register.service.ts # 注册业务服务
|
||||
├── auth.module.ts # 业务模块配置
|
||||
└── README.md # 模块文档
|
||||
```
|
||||
## 密码管理功能
|
||||
|
||||
## 对外提供的接口
|
||||
### sendPasswordResetCode()
|
||||
发送密码重置验证码到用户邮箱或手机号,支持测试模式和真实发送模式。
|
||||
|
||||
### LoginService
|
||||
### resetPassword()
|
||||
使用验证码重置用户密码,包含密码强度验证和安全检查。
|
||||
|
||||
#### login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>>
|
||||
处理用户登录请求,验证用户凭据并生成JWT令牌,支持Zulip账号验证和更新。
|
||||
### changePassword()
|
||||
修改用户密码,验证旧密码并应用新密码强度规则。
|
||||
|
||||
#### githubOAuth(oauthRequest: GitHubOAuthRequest): Promise<ApiResponse<LoginResponse>>
|
||||
使用GitHub账户登录或注册,自动创建用户账号并生成JWT令牌。
|
||||
## 邮箱验证功能
|
||||
|
||||
#### verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise<ApiResponse<LoginResponse>>
|
||||
使用邮箱或手机号和验证码进行登录,无需密码即可完成认证。
|
||||
### sendEmailVerification()
|
||||
发送邮箱验证码,用于注册时的邮箱验证和账户安全验证。
|
||||
|
||||
#### sendPasswordResetCode(identifier: string): Promise<ApiResponse<{verification_code?: string; is_test_mode?: boolean}>>
|
||||
向用户邮箱或手机发送密码重置验证码,支持测试模式和真实发送模式。
|
||||
### verifyEmailCode()
|
||||
验证邮箱验证码,确认邮箱所有权并更新用户验证状态。
|
||||
|
||||
#### resetPassword(resetRequest: PasswordResetRequest): Promise<ApiResponse>
|
||||
使用验证码重置用户密码,验证验证码有效性后更新密码。
|
||||
### resendEmailVerification()
|
||||
重新发送邮箱验证码,处理验证码过期或丢失的情况。
|
||||
|
||||
#### changePassword(userId: bigint, oldPassword: string, newPassword: string): Promise<ApiResponse>
|
||||
修改用户密码,需要验证旧密码正确性后才能更新为新密码。
|
||||
### sendLoginVerificationCode()
|
||||
发送登录验证码,支持验证码登录功能。
|
||||
|
||||
#### refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>>
|
||||
使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。
|
||||
## 调试和管理功能
|
||||
|
||||
#### sendLoginVerificationCode(identifier: string): Promise<ApiResponse<{verification_code?: string; is_test_mode?: boolean}>>
|
||||
向用户邮箱或手机发送登录验证码,用于验证码登录功能。
|
||||
### debugVerificationCode()
|
||||
获取验证码调试信息,用于开发环境的测试和调试。
|
||||
|
||||
### RegisterService
|
||||
## HTTP API接口
|
||||
|
||||
#### register(registerRequest: RegisterRequest): Promise<ApiResponse<RegisterResponse>>
|
||||
处理用户注册请求,创建游戏账号和Zulip账号,支持邮箱验证和自动回滚。
|
||||
### POST /auth/login
|
||||
用户登录接口,接受用户名/邮箱/手机号和密码,返回JWT令牌和用户信息。
|
||||
|
||||
#### sendEmailVerification(email: string): Promise<ApiResponse<{verification_code?: string; is_test_mode?: boolean}>>
|
||||
向指定邮箱发送验证码,支持测试模式和真实发送模式。
|
||||
### POST /auth/register
|
||||
用户注册接口,创建新用户账户并可选择性创建Zulip账号。
|
||||
|
||||
#### verifyEmailCode(email: string, code: string): Promise<ApiResponse>
|
||||
验证邮箱验证码的有效性,用于邮箱验证流程。
|
||||
### POST /auth/github
|
||||
GitHub OAuth登录接口,处理GitHub第三方登录和账户绑定。
|
||||
|
||||
#### resendEmailVerification(email: string): Promise<ApiResponse<{verification_code?: string; is_test_mode?: boolean}>>
|
||||
重新向指定邮箱发送验证码,用于验证码过期或未收到的情况。
|
||||
### POST /auth/forgot-password
|
||||
发送密码重置验证码接口,支持邮箱和手机号找回密码。
|
||||
|
||||
## 依赖关系
|
||||
### POST /auth/reset-password
|
||||
重置密码接口,使用验证码验证身份并设置新密码。
|
||||
|
||||
```
|
||||
Gateway Layer (auth.gateway.module)
|
||||
↓ 使用
|
||||
Business Layer (auth.module)
|
||||
↓ 依赖
|
||||
Core Layer (login_core.module, zulip_core.module)
|
||||
```
|
||||
### PUT /auth/change-password
|
||||
修改密码接口,需要验证旧密码并设置新密码。
|
||||
|
||||
### POST /auth/send-email-verification
|
||||
发送邮箱验证码接口,用于邮箱验证流程。
|
||||
|
||||
### POST /auth/verify-email
|
||||
验证邮箱验证码接口,确认邮箱所有权。
|
||||
|
||||
### POST /auth/resend-email-verification
|
||||
重新发送邮箱验证码接口,处理验证码重发需求。
|
||||
|
||||
### POST /auth/verification-code-login
|
||||
验证码登录接口,支持无密码登录方式。
|
||||
|
||||
### POST /auth/send-login-verification-code
|
||||
发送登录验证码接口,为验证码登录提供验证码。
|
||||
|
||||
### POST /auth/refresh-token
|
||||
刷新JWT令牌接口,使用刷新令牌获取新的访问令牌。
|
||||
|
||||
### POST /auth/debug-verification-code
|
||||
调试验证码接口,获取验证码状态和调试信息。
|
||||
|
||||
### POST /auth/debug-clear-throttle
|
||||
清除限流记录接口,仅用于开发环境调试。
|
||||
|
||||
## 认证和授权组件
|
||||
|
||||
### JwtAuthGuard
|
||||
JWT认证守卫,验证请求中的Bearer令牌并提取用户信息到请求上下文。
|
||||
|
||||
### CurrentUser
|
||||
当前用户装饰器,从请求上下文中提取认证用户信息,支持获取完整用户对象或特定属性。
|
||||
|
||||
## 使用的项目内部依赖
|
||||
|
||||
### LoginCoreService (来自 core/login_core)
|
||||
核心登录服务,提供用户认证、JWT令牌生成、密码验证、验证码管理等技术实现。
|
||||
### LoginCoreService (来自 core/login_core/login_core.service)
|
||||
登录核心服务,提供用户认证、密码管理、JWT令牌生成验证等核心功能实现。
|
||||
|
||||
### ZulipAccountService (来自 core/zulip_core)
|
||||
Zulip账号服务,提供Zulip账号创建、API Key管理、账号验证等功能。
|
||||
### ZulipAccountService (来自 core/zulip_core/services/zulip_account.service)
|
||||
Zulip账号服务,处理Zulip账号的创建、管理和API Key安全存储。
|
||||
|
||||
### ApiKeySecurityService (来自 core/zulip_core)
|
||||
API Key安全服务,负责Zulip API Key的加密存储和Redis缓存管理。
|
||||
### ZulipAccountsService (来自 core/db/zulip_accounts/zulip_accounts.service)
|
||||
Zulip账号数据服务,管理游戏用户与Zulip账号的关联关系数据。
|
||||
|
||||
### ZulipAccountsService (来自 core/db/zulip_accounts)
|
||||
Zulip账号数据访问服务,提供游戏账号与Zulip账号的关联管理。
|
||||
### ApiKeySecurityService (来自 core/zulip_core/services/api_key_security.service)
|
||||
API Key安全服务,负责Zulip API Key的加密存储和安全管理。
|
||||
|
||||
### Users (来自 core/db/users)
|
||||
用户实体,定义用户数据结构和数据库映射关系。
|
||||
### Users (来自 core/db/users/users.entity)
|
||||
用户实体类,定义用户数据结构和数据库映射关系。
|
||||
|
||||
## 核心原则
|
||||
### UserStatus (来自 business/user_mgmt/user_status.enum)
|
||||
用户状态枚举,定义用户的激活、禁用、待验证等状态值。
|
||||
|
||||
### 1. 专注业务逻辑,不处理HTTP协议
|
||||
### LoginDto, RegisterDto (本模块)
|
||||
登录和注册数据传输对象,提供完整的数据验证规则和类型定义。
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:返回统一的业务响应
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
// 1. 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 2. 验证Zulip账号
|
||||
await this.validateAndUpdateZulipApiKey(authResult.user);
|
||||
|
||||
// 3. 生成JWT令牌
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 4. 返回业务响应
|
||||
return {
|
||||
success: true,
|
||||
data: { user: this.formatUserInfo(authResult.user), ...tokenPair },
|
||||
message: '登录成功'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
error_code: 'LOGIN_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
### LoginResponseDto, RegisterResponseDto (本模块)
|
||||
登录和注册响应数据传输对象,定义API响应的数据结构和格式。
|
||||
|
||||
### 2. 协调多个核心服务
|
||||
|
||||
```typescript
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<RegisterResponse>> {
|
||||
// 1. 初始化Zulip管理员客户端
|
||||
await this.initializeZulipAdminClient();
|
||||
|
||||
// 2. 调用核心服务进行注册
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
// 3. 创建Zulip账号
|
||||
await this.createZulipAccountForUser(authResult.user, registerRequest.password);
|
||||
|
||||
// 4. 生成JWT令牌
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 5. 返回完整的业务响应
|
||||
return { success: true, data: { ... }, message: '注册成功' };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 统一的响应格式
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 业务服务
|
||||
|
||||
### LoginService
|
||||
|
||||
负责登录相关的业务逻辑:
|
||||
|
||||
- `login()` - 用户登录
|
||||
- `githubOAuth()` - GitHub OAuth登录
|
||||
- `verificationCodeLogin()` - 验证码登录
|
||||
- `sendPasswordResetCode()` - 发送密码重置验证码
|
||||
- `resetPassword()` - 重置密码
|
||||
- `changePassword()` - 修改密码
|
||||
- `refreshAccessToken()` - 刷新访问令牌
|
||||
- `sendLoginVerificationCode()` - 发送登录验证码
|
||||
|
||||
### RegisterService
|
||||
|
||||
负责注册相关的业务逻辑:
|
||||
|
||||
- `register()` - 用户注册
|
||||
- `sendEmailVerification()` - 发送邮箱验证码
|
||||
- `verifyEmailCode()` - 验证邮箱验证码
|
||||
- `resendEmailVerification()` - 重新发送邮箱验证码
|
||||
### ThrottlePresets, TimeoutPresets (来自 core/security_core/decorators)
|
||||
安全防护预设配置,提供限流和超时控制的标准配置。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### Zulip集成
|
||||
- **自动创建Zulip账号**:注册时同步创建Zulip聊天账号
|
||||
- **API Key管理**:安全存储和验证Zulip API Key
|
||||
- **账号关联**:建立游戏账号与Zulip账号的映射关系
|
||||
- **失败回滚**:Zulip账号创建失败时自动回滚游戏账号
|
||||
### 多种登录方式支持
|
||||
- 用户名/邮箱/手机号密码登录
|
||||
- GitHub OAuth第三方登录
|
||||
- 邮箱/手机号验证码登录
|
||||
- 自动识别登录标识符类型
|
||||
|
||||
### JWT令牌管理
|
||||
- **双令牌机制**:访问令牌(短期)+ 刷新令牌(长期)
|
||||
- **无感知续期**:通过刷新令牌自动更新访问令牌
|
||||
- **令牌验证**:完整的令牌签名和过期时间验证
|
||||
- 访问令牌和刷新令牌双令牌机制
|
||||
- 令牌自动刷新和过期处理
|
||||
- 安全的令牌签名和验证
|
||||
- 用户信息载荷和权限控制
|
||||
|
||||
### 统一响应格式
|
||||
- **ApiResponse接口**:统一的业务响应格式
|
||||
- **错误代码**:标准化的错误代码定义
|
||||
- **成功/失败标识**:明确的success字段
|
||||
### Zulip集成支持
|
||||
- 注册时自动创建Zulip账号
|
||||
- 游戏用户与Zulip账号关联管理
|
||||
- API Key安全存储和加密
|
||||
- 注册失败时的回滚机制
|
||||
|
||||
### 数据转换和格式化
|
||||
- **用户信息格式化**:将Core层数据转换为业务数据
|
||||
- **BigInt处理**:自动将bigint类型转换为string
|
||||
- **敏感信息过滤**:响应中不包含密码等敏感数据
|
||||
### 邮箱验证系统
|
||||
- 注册时邮箱验证流程
|
||||
- 密码重置邮箱验证
|
||||
- 验证码生成和过期管理
|
||||
- 测试模式和生产模式支持
|
||||
|
||||
### 完整的错误处理
|
||||
- **业务异常捕获**:捕获所有业务逻辑异常
|
||||
- **详细日志记录**:记录操作ID、用户ID、错误信息、执行时间
|
||||
- **友好错误消息**:返回用户可理解的错误提示
|
||||
### 安全防护机制
|
||||
- 请求频率限制和防暴力破解
|
||||
- 密码强度验证和安全存储
|
||||
- 用户状态检查和权限控制
|
||||
- 详细的安全审计日志
|
||||
|
||||
## 业务流程示例
|
||||
|
||||
### 用户注册流程
|
||||
|
||||
```
|
||||
1. 接收注册请求
|
||||
↓
|
||||
2. 初始化Zulip管理员客户端
|
||||
↓
|
||||
3. 调用LoginCoreService.register()创建游戏用户
|
||||
↓
|
||||
4. 创建Zulip账号并建立关联
|
||||
├─ 创建Zulip账号
|
||||
├─ 获取API Key
|
||||
├─ 存储到Redis
|
||||
└─ 创建数据库关联记录
|
||||
↓
|
||||
5. 生成JWT令牌对
|
||||
↓
|
||||
6. 返回注册成功响应
|
||||
```
|
||||
|
||||
### 用户登录流程
|
||||
|
||||
```
|
||||
1. 接收登录请求
|
||||
↓
|
||||
2. 调用LoginCoreService.login()验证用户
|
||||
↓
|
||||
3. 验证并更新Zulip API Key
|
||||
├─ 查找Zulip账号关联
|
||||
├─ 从Redis获取API Key
|
||||
├─ 验证API Key有效性
|
||||
└─ 如果无效,重新生成
|
||||
↓
|
||||
4. 生成JWT令牌对
|
||||
↓
|
||||
5. 返回登录成功响应
|
||||
```
|
||||
|
||||
## 与其他层的交互
|
||||
|
||||
### 与Gateway层的交互
|
||||
|
||||
Gateway层调用Business层服务:
|
||||
|
||||
```typescript
|
||||
// Gateway Layer
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.login({
|
||||
identifier: loginDto.identifier,
|
||||
password: loginDto.password
|
||||
});
|
||||
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
```
|
||||
|
||||
### 与Core层的交互
|
||||
|
||||
Business层调用Core层服务:
|
||||
|
||||
```typescript
|
||||
// Business Layer
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
// 调用核心服务
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 返回业务响应
|
||||
return { success: true, data: { ... } };
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **业务逻辑集中**:所有业务规则都在Business层实现
|
||||
2. **服务协调**:协调多个Core层服务完成复杂业务
|
||||
3. **错误处理**:捕获异常并转换为业务错误
|
||||
4. **日志记录**:记录关键业务操作和错误
|
||||
5. **事务管理**:处理跨服务的数据一致性
|
||||
6. **数据转换**:将Core层数据转换为业务数据
|
||||
### 业务流程控制
|
||||
- 完整的错误处理和异常管理
|
||||
- 统一的响应格式和状态码
|
||||
- 业务规则验证和数据完整性
|
||||
- 操作日志和性能监控
|
||||
|
||||
## 潜在风险
|
||||
|
||||
### Zulip账号创建失败风险
|
||||
- **风险描述**:注册流程中Zulip账号创建可能失败
|
||||
- **影响范围**:导致注册流程中断,已创建的游戏账号需要回滚
|
||||
- **缓解措施**:完整的事务回滚机制和错误日志记录
|
||||
- Zulip服务不可用时注册流程可能失败
|
||||
- 网络异常导致账号创建不完整
|
||||
- 建议实现重试机制和降级策略,允许跳过Zulip账号创建
|
||||
|
||||
### API Key验证失败风险
|
||||
- **风险描述**:登录时Zulip API Key可能已失效或不存在
|
||||
- **影响范围**:用户无法使用Zulip聊天功能
|
||||
- **缓解措施**:API Key验证失败不影响登录流程,记录警告日志,尝试重新生成
|
||||
### 验证码发送依赖风险
|
||||
- 邮件服务配置错误导致验证码无法发送
|
||||
- 测试模式下验证码泄露到日志中
|
||||
- 建议完善邮件服务监控和测试模式安全控制
|
||||
|
||||
### 跨服务事务一致性风险
|
||||
- **风险描述**:涉及多个Core层服务的协调操作,部分操作成功部分失败
|
||||
- **影响范围**:数据不一致,如游戏账号创建成功但Zulip账号创建失败
|
||||
- **缓解措施**:明确的操作顺序、完整的错误处理、自动回滚机制
|
||||
### JWT令牌安全风险
|
||||
- 令牌泄露可能导致账户被盗用
|
||||
- 刷新令牌长期有效增加安全风险
|
||||
- 建议实现令牌黑名单机制和异常登录检测
|
||||
|
||||
### 业务逻辑复杂度风险
|
||||
- **风险描述**:登录和注册流程涉及多个步骤和服务,代码复杂度高
|
||||
- **影响范围**:增加维护难度,容易引入bug
|
||||
- **缓解措施**:详细的注释、完整的测试覆盖(41个测试用例)、清晰的日志记录
|
||||
### 并发操作风险
|
||||
- 同时注册相同用户名可能导致数据冲突
|
||||
- 高并发场景下验证码生成可能重复
|
||||
- 建议加强数据库唯一性约束和分布式锁机制
|
||||
|
||||
### 验证码发送失败风险
|
||||
- **风险描述**:邮件服务不可用或配置错误导致验证码无法发送
|
||||
- **影响范围**:用户无法完成邮箱验证、密码重置、验证码登录
|
||||
- **缓解措施**:测试模式支持、详细的错误日志、邮件服务健康检查
|
||||
### 第三方服务依赖风险
|
||||
- GitHub OAuth服务不可用影响第三方登录
|
||||
- Zulip服务异常影响账号同步功能
|
||||
- 建议实现服务降级和故障转移机制
|
||||
|
||||
## 注意事项
|
||||
### 密码安全风险
|
||||
- 弱密码策略可能导致账户安全问题
|
||||
- 密码重置流程可能被恶意利用
|
||||
- 建议加强密码策略和增加二次验证机制
|
||||
|
||||
- Business层不应该处理HTTP协议
|
||||
- Business层不应该直接访问数据库(通过Core层)
|
||||
- Business层不应该包含技术实现细节
|
||||
- 所有业务逻辑都应该有完善的错误处理
|
||||
- 关键业务操作都应该有日志记录
|
||||
## 补充信息
|
||||
|
||||
### 版本信息
|
||||
- 模块版本:1.0.2
|
||||
- 最后修改:2026-01-07
|
||||
- 作者:moyin
|
||||
- 创建时间:2025-12-17
|
||||
|
||||
### 架构优化记录
|
||||
- 2026-01-07:将JWT技术实现从Business层移至Core层,符合分层架构原则
|
||||
- 2026-01-07:完成代码规范优化,统一注释格式和文件命名规范
|
||||
- 2026-01-07:完善测试覆盖,确保所有公共方法都有对应的单元测试
|
||||
|
||||
### 已知限制
|
||||
- 短信验证码功能尚未实现,目前仅支持邮箱验证码
|
||||
- Zulip账号创建失败时的重试机制有待完善
|
||||
- 多设备登录管理和会话控制功能待开发
|
||||
|
||||
### 改进建议
|
||||
- 实现短信验证码发送功能,完善多渠道验证
|
||||
- 增加社交登录支持(微信、QQ等)
|
||||
- 实现多因素认证(MFA)提升账户安全
|
||||
- 添加登录设备管理和异常登录检测
|
||||
- 完善Zulip集成的错误处理和重试机制
|
||||
@@ -1,60 +1,47 @@
|
||||
/**
|
||||
* 用户认证业务模块
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合所有用户认证相关的业务逻辑
|
||||
* - 用户登录、注册、密码管理业务流程
|
||||
* - GitHub OAuth业务集成
|
||||
* - 邮箱验证业务功能
|
||||
* - Zulip账号关联业务
|
||||
* - 整合所有用户认证相关功能
|
||||
* - 用户登录、注册、密码管理
|
||||
* - GitHub OAuth集成
|
||||
* - 邮箱验证功能
|
||||
* - JWT令牌管理和验证
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于业务逻辑实现和流程控制
|
||||
* - 整合核心服务完成业务功能
|
||||
* - 不包含HTTP协议处理(由Gateway层负责)
|
||||
* - 不包含数据访问细节(由Core层负责)
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 Core Layer 的 LoginCoreModule
|
||||
* - 依赖 Core Layer 的 ZulipCoreModule
|
||||
* - 被 Gateway Layer 的 AuthGatewayModule 使用
|
||||
* - 专注于认证业务模块的依赖注入和配置
|
||||
* - 整合核心服务和业务服务
|
||||
* - 提供JWT模块的统一配置
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 架构重构 - 移除Controller,专注于业务逻辑层
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 代码规范优化 - 更新注释规范,修正文件引用路径
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-24
|
||||
* @lastModified 2026-01-14
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginController } from './login.controller';
|
||||
import { LoginService } from './login.service';
|
||||
import { RegisterService } from './register.service';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||
import { UsersModule } from '../../core/db/users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 导入核心层模块
|
||||
LoginCoreModule,
|
||||
ZulipCoreModule,
|
||||
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||
ZulipAccountsModule.forRoot(),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [LoginController],
|
||||
providers: [
|
||||
// 业务服务
|
||||
LoginService,
|
||||
RegisterService,
|
||||
],
|
||||
exports: [
|
||||
// 导出业务服务供Gateway层使用
|
||||
LoginService,
|
||||
RegisterService,
|
||||
],
|
||||
exports: [LoginService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -2,30 +2,36 @@
|
||||
* 用户认证业务模块导出
|
||||
*
|
||||
* 功能概述:
|
||||
* - 用户登录和注册业务逻辑
|
||||
* - 用户登录和注册
|
||||
* - GitHub OAuth集成
|
||||
* - 密码管理(忘记密码、重置密码、修改密码)
|
||||
* - 邮箱验证功能
|
||||
* - JWT Token管理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于业务层模块导出
|
||||
* - 提供统一的业务服务入口点
|
||||
* - 专注于模块导出和接口暴露
|
||||
* - 提供统一的模块入口点
|
||||
* - 简化外部模块的引用方式
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 架构重构 - 移除Controller和DTO导出(已移至Gateway层)(修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 代码规范优化 - 更新注释规范
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-14
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
// 模块
|
||||
export * from './auth.module';
|
||||
|
||||
// 服务(业务层)
|
||||
export { LoginService } from './login.service';
|
||||
export { RegisterService } from './register.service';
|
||||
// 控制器
|
||||
export * from './login.controller';
|
||||
|
||||
// 服务
|
||||
export * from './login.service';
|
||||
|
||||
// DTO
|
||||
export * from './login.dto';
|
||||
export * from './login_response.dto';
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* JWT 使用示例
|
||||
*
|
||||
* 架构层级:Gateway Layer(网关层)
|
||||
*
|
||||
* 功能描述:
|
||||
* - 展示如何在控制器中使用 JWT 认证守卫和当前用户装饰器
|
||||
* - 提供完整的JWT认证使用示例和最佳实践
|
||||
@@ -13,20 +11,14 @@
|
||||
* - 提供开发者参考的代码示例
|
||||
* - 展示认证守卫和装饰器的最佳实践
|
||||
*
|
||||
* 架构说明:
|
||||
* - 本文件位于Gateway层,符合Controller的架构定位
|
||||
* - JWT Guard和装饰器位于同层(src/gateway/auth)
|
||||
* - 本文件作为使用示例参考
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 架构优化 - 将文件从Business层移至Gateway层,符合架构分层原则 (Modified by: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 修正导入路径,指向Gateway层 (Modified by: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 代码规范优化 - 文件重命名为snake_case格式,更新注释规范
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.2.0
|
||||
* @version 1.0.2
|
||||
* @since 2025-01-05
|
||||
* @lastModified 2026-01-14
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
|
||||
@@ -1,91 +1,64 @@
|
||||
/**
|
||||
* 登录网关控制器
|
||||
*
|
||||
* 架构层级:Gateway Layer(网关层)
|
||||
* 登录控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理登录相关的HTTP请求和响应
|
||||
* - 提供RESTful API接口
|
||||
* - 数据验证和格式化
|
||||
* - 协议处理和错误响应
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于HTTP协议处理和请求响应
|
||||
* - 调用业务层服务完成具体功能
|
||||
* - 专注于HTTP请求处理和响应格式化
|
||||
* - 调用业务服务完成具体功能
|
||||
* - 处理API文档和参数验证
|
||||
* - 不包含业务逻辑,只做数据转换和路由
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 Business Layer 的 LoginService
|
||||
* - 使用 DTO 进行数据验证
|
||||
* - 使用 Guard 进行认证保护
|
||||
*
|
||||
* API端点:
|
||||
* - POST /auth/login - 用户登录
|
||||
* - POST /auth/register - 用户注册
|
||||
* - POST /auth/github - GitHub OAuth登录
|
||||
* - POST /auth/forgot-password - 发送密码重置验证码
|
||||
* - POST /auth/reset-password - 重置密码
|
||||
* - PUT /auth/change-password - 修改密码
|
||||
* - POST /auth/refresh-token - 刷新访问令牌
|
||||
* - POST /auth/verification-code-login - 验证码登录
|
||||
* - POST /auth/send-login-verification-code - 发送登录验证码
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 代码规范优化 - 更新注释规范,修正文件引用路径
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
ValidationPipe,
|
||||
UsePipes,
|
||||
Logger,
|
||||
Res
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse as SwaggerApiResponse,
|
||||
ApiBody
|
||||
} from '@nestjs/swagger';
|
||||
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { LoginService } from '../../business/auth/login.service';
|
||||
import {
|
||||
LoginDto,
|
||||
GitHubOAuthDto,
|
||||
ForgotPasswordDto,
|
||||
ResetPasswordDto,
|
||||
ChangePasswordDto,
|
||||
VerificationCodeLoginDto,
|
||||
SendLoginVerificationCodeDto,
|
||||
RefreshTokenDto,
|
||||
SendEmailVerificationDto
|
||||
} from './dto/login.dto';
|
||||
import { LoginService, ApiResponse, LoginResponse } from './login.service';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from './login.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
RegisterResponseDto,
|
||||
GitHubOAuthResponseDto,
|
||||
ForgotPasswordResponseDto,
|
||||
CommonResponseDto,
|
||||
TestModeEmailVerificationResponseDto,
|
||||
SuccessEmailVerificationResponseDto,
|
||||
RefreshTokenResponseDto
|
||||
} from './dto/login_response.dto';
|
||||
} from './login_response.dto';
|
||||
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
|
||||
import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decorator';
|
||||
|
||||
// 错误代码到HTTP状态码的映射
|
||||
const ERROR_STATUS_MAP = {
|
||||
LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
REGISTER_FAILED: HttpStatus.BAD_REQUEST,
|
||||
TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT,
|
||||
TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
SEND_CODE_FAILED: HttpStatus.BAD_REQUEST,
|
||||
RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
|
||||
CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
|
||||
EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST,
|
||||
VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST,
|
||||
} as const;
|
||||
@@ -100,10 +73,10 @@ export class LoginController {
|
||||
/**
|
||||
* 通用响应处理方法
|
||||
*
|
||||
* 职责:
|
||||
* - 根据业务结果设置HTTP状态码
|
||||
* - 处理不同类型的错误响应
|
||||
* - 统一响应格式和错误处理
|
||||
* 业务逻辑:
|
||||
* 1. 根据业务结果设置HTTP状态码
|
||||
* 2. 处理不同类型的错误响应
|
||||
* 3. 统一响应格式和错误处理
|
||||
*
|
||||
* @param result 业务服务返回的结果
|
||||
* @param res Express响应对象
|
||||
@@ -116,6 +89,7 @@ export class LoginController {
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据错误代码获取状态码
|
||||
const statusCode = this.getErrorStatusCode(result);
|
||||
res.status(statusCode).json(result);
|
||||
}
|
||||
@@ -128,10 +102,12 @@ export class LoginController {
|
||||
* @private
|
||||
*/
|
||||
private getErrorStatusCode(result: any): HttpStatus {
|
||||
// 优先使用错误代码映射
|
||||
if (result.error_code && ERROR_STATUS_MAP[result.error_code as keyof typeof ERROR_STATUS_MAP]) {
|
||||
return ERROR_STATUS_MAP[result.error_code as keyof typeof ERROR_STATUS_MAP];
|
||||
}
|
||||
|
||||
// 根据消息内容判断
|
||||
if (result.message?.includes('已存在') || result.message?.includes('已被注册')) {
|
||||
return HttpStatus.CONFLICT;
|
||||
}
|
||||
@@ -144,6 +120,7 @@ export class LoginController {
|
||||
return HttpStatus.NOT_FOUND;
|
||||
}
|
||||
|
||||
// 默认返回400
|
||||
return HttpStatus.BAD_REQUEST;
|
||||
}
|
||||
|
||||
@@ -151,7 +128,7 @@ export class LoginController {
|
||||
* 用户登录
|
||||
*
|
||||
* @param loginDto 登录数据
|
||||
* @param res Express响应对象
|
||||
* @returns 登录结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '用户登录',
|
||||
@@ -179,7 +156,7 @@ export class LoginController {
|
||||
status: 429,
|
||||
description: '登录尝试过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.LOGIN_PER_ACCOUNT)
|
||||
@Throttle(ThrottlePresets.LOGIN)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('login')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
@@ -192,11 +169,56 @@ export class LoginController {
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerDto 注册数据
|
||||
* @returns 注册结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '用户注册',
|
||||
description: '创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码,然后在注册时提供验证码进行验证。'
|
||||
})
|
||||
@ApiBody({ type: RegisterDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 201,
|
||||
description: '注册成功',
|
||||
type: RegisterResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 409,
|
||||
description: '用户名或邮箱已存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '注册请求过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.REGISTER)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('register')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async register(@Body() registerDto: RegisterDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.register({
|
||||
username: registerDto.username,
|
||||
password: registerDto.password,
|
||||
nickname: registerDto.nickname,
|
||||
email: registerDto.email,
|
||||
phone: registerDto.phone,
|
||||
email_verification_code: registerDto.email_verification_code
|
||||
});
|
||||
|
||||
this.handleResponse(result, res, HttpStatus.CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth登录
|
||||
*
|
||||
* @param githubDto GitHub OAuth数据
|
||||
* @param res Express响应对象
|
||||
* @returns 登录结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: 'GitHub OAuth登录',
|
||||
@@ -235,6 +257,7 @@ export class LoginController {
|
||||
*
|
||||
* @param forgotPasswordDto 忘记密码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '发送密码重置验证码',
|
||||
@@ -278,7 +301,7 @@ export class LoginController {
|
||||
* 重置密码
|
||||
*
|
||||
* @param resetPasswordDto 重置密码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 重置结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '重置密码',
|
||||
@@ -319,7 +342,7 @@ export class LoginController {
|
||||
* 修改密码
|
||||
*
|
||||
* @param changePasswordDto 修改密码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 修改结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '修改密码',
|
||||
@@ -342,6 +365,8 @@ export class LoginController {
|
||||
@Put('change-password')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async changePassword(@Body() changePasswordDto: ChangePasswordDto, @Res() res: Response): Promise<void> {
|
||||
// 实际应用中应从JWT令牌中获取用户ID
|
||||
// 这里为了演示,使用请求体中的用户ID
|
||||
const userId = BigInt(changePasswordDto.user_id);
|
||||
|
||||
const result = await this.loginService.changePassword(
|
||||
@@ -353,11 +378,125 @@ export class LoginController {
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param sendEmailVerificationDto 发送验证码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '发送邮箱验证码',
|
||||
description: '向指定邮箱发送验证码'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码发送成功(真实发送模式)',
|
||||
type: SuccessEmailVerificationResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 206,
|
||||
description: '测试模式:验证码已生成但未真实发送',
|
||||
type: TestModeEmailVerificationResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE)
|
||||
@Timeout(TimeoutPresets.EMAIL_SEND)
|
||||
@Post('send-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async sendEmailVerification(
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.sendEmailVerification(sendEmailVerificationDto.email);
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱验证码
|
||||
*
|
||||
* @param emailVerificationDto 邮箱验证数据
|
||||
* @returns 验证结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '验证邮箱验证码',
|
||||
description: '使用验证码验证邮箱'
|
||||
})
|
||||
@ApiBody({ type: EmailVerificationDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '邮箱验证成功',
|
||||
type: CommonResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '验证码错误或已过期'
|
||||
})
|
||||
@Post('verify-email')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.verifyEmailCode(
|
||||
emailVerificationDto.email,
|
||||
emailVerificationDto.verification_code
|
||||
);
|
||||
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发送邮箱验证码
|
||||
*
|
||||
* @param sendEmailVerificationDto 发送验证码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '重新发送邮箱验证码',
|
||||
description: '重新向指定邮箱发送验证码'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码重新发送成功',
|
||||
type: ForgotPasswordResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 206,
|
||||
description: '测试模式:验证码已生成但未真实发送',
|
||||
type: ForgotPasswordResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '邮箱已验证或用户不存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE)
|
||||
@Post('resend-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async resendEmailVerification(
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
* @param verificationCodeLoginDto 验证码登录数据
|
||||
* @param res Express响应对象
|
||||
* @returns 登录结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '验证码登录',
|
||||
@@ -384,16 +523,11 @@ export class LoginController {
|
||||
@Post('verification-code-login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async verificationCodeLogin(
|
||||
@Body() verificationCodeLoginDto: VerificationCodeLoginDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.verificationCodeLogin({
|
||||
async verificationCodeLogin(@Body() verificationCodeLoginDto: VerificationCodeLoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||
return await this.loginService.verificationCodeLogin({
|
||||
identifier: verificationCodeLoginDto.identifier,
|
||||
verificationCode: verificationCodeLoginDto.verification_code
|
||||
});
|
||||
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,10 +535,11 @@ export class LoginController {
|
||||
*
|
||||
* @param sendLoginVerificationCodeDto 发送验证码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '发送登录验证码',
|
||||
description: '向用户邮箱或手机发送登录验证码'
|
||||
description: '向用户邮箱或手机发送登录验证码。邮件使用专门的登录验证码模板,内容明确标识为登录验证而非密码重置。'
|
||||
})
|
||||
@ApiBody({ type: SendLoginVerificationCodeDto })
|
||||
@SwaggerApiResponse({
|
||||
@@ -439,15 +574,63 @@ export class LoginController {
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试验证码信息
|
||||
* 仅用于开发和调试
|
||||
*
|
||||
* @param sendEmailVerificationDto 邮箱信息
|
||||
* @returns 验证码调试信息
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '调试验证码信息',
|
||||
description: '获取验证码的详细调试信息(仅开发环境)'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@Post('debug-verification-code')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async debugVerificationCode(@Body() sendEmailVerificationDto: SendEmailVerificationDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
|
||||
|
||||
// 调试接口总是返回200
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除限流记录(仅开发环境)
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '清除限流记录',
|
||||
description: '清除所有限流记录(仅开发环境使用)'
|
||||
})
|
||||
@Post('debug-clear-throttle')
|
||||
async clearThrottle(@Res() res: Response): Promise<void> {
|
||||
// 注入ThrottleGuard并清除记录
|
||||
// 这里需要通过依赖注入获取ThrottleGuard实例
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
message: '限流记录已清除'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证刷新令牌的有效性和格式
|
||||
* 2. 检查用户状态是否正常
|
||||
* 3. 生成新的JWT令牌对
|
||||
* 4. 返回新的访问令牌和刷新令牌
|
||||
*
|
||||
* @param refreshTokenDto 刷新令牌数据
|
||||
* @param res Express响应对象
|
||||
* @returns 新的令牌对
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '刷新访问令牌',
|
||||
description: '使用有效的刷新令牌生成新的访问令牌'
|
||||
description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。'
|
||||
})
|
||||
@ApiBody({ type: RefreshTokenDto })
|
||||
@SwaggerApiResponse({
|
||||
@@ -476,28 +659,73 @@ export class LoginController {
|
||||
@Post('refresh-token')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise<void> {
|
||||
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
|
||||
this.handleResponse(result, res);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logRefreshTokenStart();
|
||||
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
|
||||
this.handleRefreshTokenResponse(result, res, startTime);
|
||||
} catch (error) {
|
||||
this.handleRefreshTokenError(error, res, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试验证码信息(仅开发环境)
|
||||
*
|
||||
* @param sendEmailVerificationDto 邮箱信息
|
||||
* @param res Express响应对象
|
||||
* 记录令牌刷新开始日志
|
||||
* @private
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '调试验证码信息',
|
||||
description: '获取验证码的详细调试信息(仅开发环境)'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@Post('debug-verification-code')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async debugVerificationCode(
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
private logRefreshTokenStart(): void {
|
||||
this.logger.log('令牌刷新请求', {
|
||||
operation: 'refreshToken',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理令牌刷新响应
|
||||
* @private
|
||||
*/
|
||||
private handleRefreshTokenResponse(result: any, res: Response, startTime: number): void {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (result.success) {
|
||||
this.logger.log('令牌刷新成功', {
|
||||
operation: 'refreshToken',
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
res.status(HttpStatus.OK).json(result);
|
||||
} else {
|
||||
this.logger.warn('令牌刷新失败', {
|
||||
operation: 'refreshToken',
|
||||
error: result.message,
|
||||
errorCode: result.error_code,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理令牌刷新异常
|
||||
* @private
|
||||
*/
|
||||
private handleRefreshTokenError(error: unknown, res: Response, startTime: number): void {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error('令牌刷新异常', {
|
||||
operation: 'refreshToken',
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
success: false,
|
||||
message: '服务器内部错误',
|
||||
error_code: 'INTERNAL_SERVER_ERROR'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -60,14 +60,18 @@ describe('LoginService', () => {
|
||||
|
||||
const mockLoginCoreService = {
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
githubOAuth: jest.fn(),
|
||||
sendPasswordResetCode: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
changePassword: jest.fn(),
|
||||
sendEmailVerification: jest.fn(),
|
||||
verifyEmailCode: jest.fn(),
|
||||
resendEmailVerification: jest.fn(),
|
||||
verificationCodeLogin: jest.fn(),
|
||||
sendLoginVerificationCode: jest.fn(),
|
||||
debugVerificationCode: jest.fn(),
|
||||
refreshAccessToken: jest.fn(),
|
||||
deleteUser: jest.fn(),
|
||||
generateTokenPair: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -174,6 +178,44 @@ describe('LoginService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register successfully with JWT tokens', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.register({
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
nickname: '新用户',
|
||||
email: 'newuser@example.com',
|
||||
email_verification_code: '123456'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.access_token).toBe(mockTokenPair.access_token);
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
expect(loginCoreService.register).toHaveBeenCalled();
|
||||
expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
|
||||
it('should handle register failure', async () => {
|
||||
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
|
||||
|
||||
const result = await service.register({
|
||||
username: 'existinguser',
|
||||
password: 'password123',
|
||||
nickname: '用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('用户名已存在');
|
||||
expect(result.error_code).toBe('REGISTER_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('githubOAuth', () => {
|
||||
it('should handle GitHub OAuth successfully', async () => {
|
||||
loginCoreService.githubOAuth.mockResolvedValue({
|
||||
@@ -240,6 +282,34 @@ describe('LoginService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmailVerification', () => {
|
||||
it('should handle sendEmailVerification in test mode', async () => {
|
||||
loginCoreService.sendEmailVerification.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendEmailVerification('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false); // Test mode returns false
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.data?.is_test_mode).toBe(true);
|
||||
expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyEmailCode', () => {
|
||||
it('should handle verifyEmailCode successfully', async () => {
|
||||
loginCoreService.verifyEmailCode.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verifyEmailCode('test@example.com', '123456');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('邮箱验证成功');
|
||||
expect(loginCoreService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verificationCodeLogin', () => {
|
||||
it('should handle verificationCodeLogin successfully', async () => {
|
||||
loginCoreService.verificationCodeLogin.mockResolvedValue({
|
||||
|
||||
@@ -2,48 +2,45 @@
|
||||
* 登录业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理用户登录相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的登录功能
|
||||
* - 处理登录相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的业务功能
|
||||
* - 处理业务规则、数据格式化和错误处理
|
||||
* - 管理JWT令牌刷新和验证码登录
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于登录业务流程和规则实现
|
||||
* - 专注于业务流程和规则实现
|
||||
* - 调用核心服务完成具体功能
|
||||
* - 为控制器层提供登录业务接口
|
||||
* - 为控制器层提供业务接口
|
||||
* - JWT技术实现已移至Core层,符合架构分层原则
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-12: 代码分离 - 移除注册相关业务逻辑,专注于登录功能
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块,符合架构分层原则
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @version 1.0.3
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-12
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { LoginCoreService, LoginRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service';
|
||||
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',
|
||||
REGISTER_FAILED: 'REGISTER_FAILED',
|
||||
GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED',
|
||||
SEND_CODE_FAILED: 'SEND_CODE_FAILED',
|
||||
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
|
||||
CHANGE_PASSWORD_FAILED: 'CHANGE_PASSWORD_FAILED',
|
||||
SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED',
|
||||
EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED',
|
||||
RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED',
|
||||
VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED',
|
||||
SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED',
|
||||
TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED',
|
||||
@@ -54,14 +51,19 @@ const ERROR_CODES = {
|
||||
|
||||
const MESSAGES = {
|
||||
LOGIN_SUCCESS: '登录成功',
|
||||
REGISTER_SUCCESS: '注册成功',
|
||||
REGISTER_SUCCESS_WITH_ZULIP: '注册成功,Zulip账号已同步创建',
|
||||
GITHUB_LOGIN_SUCCESS: 'GitHub登录成功',
|
||||
GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功',
|
||||
PASSWORD_RESET_SUCCESS: '密码重置成功',
|
||||
PASSWORD_CHANGE_SUCCESS: '密码修改成功',
|
||||
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
|
||||
VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功',
|
||||
TOKEN_REFRESH_SUCCESS: '令牌刷新成功',
|
||||
DEBUG_INFO_SUCCESS: '调试信息获取成功',
|
||||
CODE_SENT: '验证码已发送,请查收',
|
||||
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
|
||||
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
|
||||
VERIFICATION_CODE_ERROR: '验证码错误',
|
||||
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
} as const;
|
||||
@@ -118,7 +120,8 @@ export class LoginService {
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly zulipAccountService: ZulipAccountService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
|
||||
@Inject('ZulipAccountsService')
|
||||
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
) {}
|
||||
|
||||
@@ -154,38 +157,10 @@ export class LoginService {
|
||||
// 1. 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 2. 验证和更新Zulip API Key(如果用户有Zulip账号关联)
|
||||
try {
|
||||
const isZulipValid = await this.validateAndUpdateZulipApiKey(authResult.user);
|
||||
if (!isZulipValid) {
|
||||
// 尝试重新生成API Key(需要密码)
|
||||
const regenerated = await this.regenerateZulipApiKey(authResult.user, loginRequest.password);
|
||||
if (regenerated) {
|
||||
this.logger.log('用户Zulip API Key已重新生成', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('用户Zulip API Key重新生成失败', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (zulipError) {
|
||||
// Zulip验证失败不影响登录流程,只记录日志
|
||||
const err = zulipError as Error;
|
||||
this.logger.warn('Zulip API Key验证失败,但不影响登录', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 生成JWT令牌对(通过Core层)
|
||||
// 2. 生成JWT令牌对(通过Core层)
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 4. 格式化响应数据
|
||||
// 3. 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
@@ -232,6 +207,121 @@ export class LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerRequest 注册请求
|
||||
* @returns 注册响应
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
||||
|
||||
// 1. 初始化Zulip管理员客户端
|
||||
await this.initializeZulipAdminClient();
|
||||
|
||||
// 2. 调用核心服务进行注册
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
// 3. 创建Zulip账号(使用相同的邮箱和密码)
|
||||
let zulipAccountCreated = false;
|
||||
try {
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
await this.createZulipAccountForUser(authResult.user, registerRequest.password);
|
||||
zulipAccountCreated = true;
|
||||
|
||||
this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, {
|
||||
operation: 'register',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`跳过Zulip账号创建:缺少邮箱或密码`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
hasEmail: !!registerRequest.email,
|
||||
hasPassword: !!registerRequest.password,
|
||||
});
|
||||
}
|
||||
} catch (zulipError) {
|
||||
const err = zulipError as Error;
|
||||
this.logger.error(`Zulip账号创建失败,回滚用户注册`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
}, err.stack);
|
||||
|
||||
// 回滚游戏用户注册
|
||||
try {
|
||||
await this.loginCoreService.deleteUser(authResult.user.id);
|
||||
this.logger.log(`用户注册回滚成功: ${registerRequest.username}`);
|
||||
} catch (rollbackError) {
|
||||
const rollbackErr = rollbackError as Error;
|
||||
this.logger.error(`用户注册回滚失败`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
rollbackError: rollbackErr.message,
|
||||
}, rollbackErr.stack);
|
||||
}
|
||||
|
||||
// 抛出原始错误
|
||||
throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`);
|
||||
}
|
||||
|
||||
// 4. 生成JWT令牌对(通过Core层)
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 5. 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: true,
|
||||
message: zulipAccountCreated ? MESSAGES.REGISTER_SUCCESS_WITH_ZULIP : MESSAGES.REGISTER_SUCCESS
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, {
|
||||
operation: 'register',
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
zulipAccountCreated,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: response.message
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error(`用户注册失败: ${registerRequest.username}`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: err.message || '注册失败',
|
||||
error_code: ERROR_CODES.REGISTER_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth登录
|
||||
*
|
||||
@@ -292,26 +382,7 @@ export class LoginService {
|
||||
|
||||
this.logger.log(`密码重置验证码已发送: ${identifier}`);
|
||||
|
||||
// 处理测试模式响应
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: MESSAGES.CODE_SENT
|
||||
};
|
||||
}
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
@@ -385,6 +456,98 @@ export class LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已发送: ${email}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param code 验证码
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async verifyEmailCode(email: string, code: string): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`验证邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务验证验证码
|
||||
const isValid = await this.loginCoreService.verifyEmailCode(email, code);
|
||||
|
||||
if (isValid) {
|
||||
this.logger.log(`邮箱验证成功: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: MESSAGES.EMAIL_VERIFICATION_SUCCESS
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: MESSAGES.VERIFICATION_CODE_ERROR,
|
||||
error_code: ERROR_CODES.INVALID_VERIFICATION_CODE
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '邮箱验证失败',
|
||||
error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`重新发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务重新发送验证码
|
||||
const result = await this.loginCoreService.resendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已重新发送: ${email}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '重新发送验证码失败',
|
||||
error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户信息
|
||||
*
|
||||
@@ -404,6 +567,41 @@ export class LoginService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理测试模式响应
|
||||
*
|
||||
* @param result 核心服务返回的结果
|
||||
* @param successMessage 成功时的消息
|
||||
* @param emailMessage 邮件发送成功时的消息
|
||||
* @returns 格式化的响应
|
||||
* @private
|
||||
*/
|
||||
private handleTestModeResponse(
|
||||
result: { code: string; isTestMode: boolean },
|
||||
successMessage: string,
|
||||
emailMessage?: string
|
||||
): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> {
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: emailMessage || successMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
@@ -464,26 +662,7 @@ export class LoginService {
|
||||
|
||||
this.logger.log(`登录验证码已发送: ${identifier}`);
|
||||
|
||||
// 处理测试模式响应
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: MESSAGES.CODE_SENT
|
||||
};
|
||||
}
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
@@ -542,13 +721,6 @@ export class LoginService {
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 调试验证码信息
|
||||
* 仅用于开发和调试
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 验证码调试信息
|
||||
*/
|
||||
async debugVerificationCode(email: string): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`调试验证码信息: ${email}`);
|
||||
@@ -572,179 +744,169 @@ export class LoginService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并更新用户的Zulip API Key
|
||||
* 初始化Zulip管理员客户端
|
||||
*
|
||||
* 功能描述:
|
||||
* 在用户登录时验证其Zulip账号的API Key是否有效,如果无效则重新获取
|
||||
* 使用环境变量中的管理员凭证初始化Zulip客户端
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 查找用户的Zulip账号关联
|
||||
* 2. 从Redis获取API Key
|
||||
* 3. 验证API Key是否有效
|
||||
* 4. 如果无效,重新生成API Key并更新存储
|
||||
* 1. 从环境变量获取管理员配置
|
||||
* 2. 验证配置完整性
|
||||
* 3. 初始化ZulipAccountService的管理员客户端
|
||||
*
|
||||
* @param user 用户信息
|
||||
* @returns Promise<boolean> 是否验证/更新成功
|
||||
* @throws Error 当配置缺失或初始化失败时
|
||||
* @private
|
||||
*/
|
||||
private async validateAndUpdateZulipApiKey(user: Users): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始验证用户Zulip API Key', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
private async initializeZulipAdminClient(): Promise<void> {
|
||||
try {
|
||||
// 1. 查找用户的Zulip账号关联
|
||||
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
|
||||
if (!zulipAccount) {
|
||||
this.logger.log('用户没有Zulip账号关联,跳过验证', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
});
|
||||
return true; // 没有关联不算错误
|
||||
// 从环境变量获取管理员配置
|
||||
const adminConfig = {
|
||||
realm: process.env.ZULIP_SERVER_URL || '',
|
||||
username: process.env.ZULIP_BOT_EMAIL || '',
|
||||
apiKey: process.env.ZULIP_BOT_API_KEY || '',
|
||||
};
|
||||
|
||||
// 验证配置完整性
|
||||
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
|
||||
throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY');
|
||||
}
|
||||
|
||||
// 2. 从Redis获取API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(user.id.toString());
|
||||
if (!apiKeyResult.success || !apiKeyResult.apiKey) {
|
||||
this.logger.warn('用户Zulip API Key不存在,需要重新生成', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
error: apiKeyResult.message,
|
||||
});
|
||||
|
||||
return false; // 需要重新生成
|
||||
// 初始化管理员客户端
|
||||
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
|
||||
|
||||
if (!initialized) {
|
||||
throw new Error('Zulip管理员客户端初始化失败');
|
||||
}
|
||||
|
||||
// 3. 验证API Key是否有效
|
||||
const validationResult = await this.zulipAccountService.validateZulipAccount(
|
||||
zulipAccount.zulipEmail,
|
||||
apiKeyResult.apiKey
|
||||
);
|
||||
|
||||
if (validationResult.success && validationResult.isValid) {
|
||||
this.logger.log('用户Zulip API Key验证成功', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. API Key无效,需要重新生成
|
||||
this.logger.warn('用户Zulip API Key无效,需要重新生成', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
validationError: validationResult.error,
|
||||
this.logger.log('Zulip管理员客户端初始化成功', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
realm: adminConfig.realm,
|
||||
adminEmail: adminConfig.username,
|
||||
});
|
||||
|
||||
return false; // 需要重新生成
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('验证用户Zulip API Key失败', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
this.logger.error('Zulip管理员客户端初始化失败', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成并更新用户的Zulip API Key
|
||||
* 为用户创建Zulip账号
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用用户密码重新生成Zulip API Key并更新存储
|
||||
* 为新注册的游戏用户创建对应的Zulip账号并建立关联
|
||||
*
|
||||
* @param user 用户信息
|
||||
* 业务逻辑:
|
||||
* 1. 使用相同的邮箱和密码创建Zulip账号
|
||||
* 2. 加密存储API Key
|
||||
* 3. 在数据库中建立关联关系
|
||||
* 4. 处理创建失败的情况
|
||||
*
|
||||
* @param gameUser 游戏用户信息
|
||||
* @param password 用户密码(明文)
|
||||
* @returns Promise<boolean> 是否更新成功
|
||||
* @throws Error 当Zulip账号创建失败时
|
||||
* @private
|
||||
*/
|
||||
private async regenerateZulipApiKey(user: Users, password: string): Promise<boolean> {
|
||||
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始重新生成用户Zulip API Key', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
email: user.email,
|
||||
this.logger.log('开始为用户创建Zulip账号', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
nickname: gameUser.nickname,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 查找用户的Zulip账号关联
|
||||
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
|
||||
if (!zulipAccount) {
|
||||
this.logger.warn('用户没有Zulip账号关联,无法重新生成API Key', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
// 1. 检查是否已存在Zulip账号关联
|
||||
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString());
|
||||
if (existingAccount) {
|
||||
this.logger.warn('用户已存在Zulip账号关联,跳过创建', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
existingZulipUserId: existingAccount.zulipUserId,
|
||||
});
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 重新生成API Key
|
||||
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
|
||||
zulipAccount.zulipEmail,
|
||||
password
|
||||
);
|
||||
// 2. 创建Zulip账号
|
||||
const createResult = await this.zulipAccountService.createZulipAccount({
|
||||
email: gameUser.email,
|
||||
fullName: gameUser.nickname,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (!apiKeyResult.success) {
|
||||
this.logger.error('重新生成Zulip API Key失败', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
error: apiKeyResult.error,
|
||||
});
|
||||
return false;
|
||||
if (!createResult.success) {
|
||||
throw new Error(createResult.error || 'Zulip账号创建失败');
|
||||
}
|
||||
|
||||
// 3. 更新Redis中的API Key
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
user.id.toString(),
|
||||
apiKeyResult.apiKey!
|
||||
);
|
||||
// 3. 存储API Key
|
||||
if (createResult.apiKey) {
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
gameUser.id.toString(),
|
||||
createResult.apiKey
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 更新内存关联
|
||||
await this.zulipAccountService.linkGameAccount(
|
||||
user.id.toString(),
|
||||
zulipAccount.zulipUserId,
|
||||
zulipAccount.zulipEmail,
|
||||
apiKeyResult.apiKey!
|
||||
);
|
||||
// 4. 在数据库中创建关联记录
|
||||
await this.zulipAccountsService.create({
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId!,
|
||||
zulipEmail: createResult.email!,
|
||||
zulipFullName: gameUser.nickname,
|
||||
zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话)
|
||||
if (createResult.apiKey) {
|
||||
await this.zulipAccountService.linkGameAccount(
|
||||
gameUser.id.toString(),
|
||||
createResult.userId!,
|
||||
createResult.email!,
|
||||
createResult.apiKey
|
||||
);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('重新生成Zulip API Key成功', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
this.logger.log('Zulip账号创建和关联成功', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId,
|
||||
zulipEmail: createResult.email,
|
||||
hasApiKey: !!createResult.apiKey,
|
||||
duration,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('重新生成Zulip API Key失败', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
this.logger.error('为用户创建Zulip账号失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
return false;
|
||||
// 清理可能创建的部分数据
|
||||
try {
|
||||
await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString());
|
||||
} catch (cleanupError) {
|
||||
this.logger.warn('清理Zulip账号关联数据失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
cleanupError: (cleanupError as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
573
src/business/auth/login.service.zulip_account.spec.ts
Normal file
573
src/business/auth/login.service.zulip_account.spec.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* LoginService Zulip账号创建属性测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试用户注册时Zulip账号创建的一致性
|
||||
* - 验证账号关联和数据完整性
|
||||
* - 测试失败回滚机制
|
||||
*
|
||||
* 属性测试:
|
||||
* - 属性 13: Zulip账号创建一致性
|
||||
* - 验证需求: 账号创建成功率和数据一致性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 文件重命名 - 修正kebab-case为snake_case命名规范 (修改者: moyin)
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.1
|
||||
* @since 2025-01-05
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fc from 'fast-check';
|
||||
import { LoginService } from './login.service';
|
||||
import { LoginCoreService, RegisterRequest } from '../../core/login_core/login_core.service';
|
||||
import { ZulipAccountService } from '../../core/zulip_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';
|
||||
|
||||
describe('LoginService - Zulip账号创建属性测试', () => {
|
||||
let loginService: LoginService;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
|
||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
||||
|
||||
// 测试用的模拟数据生成器
|
||||
const validEmailArb = fc.string({ minLength: 5, maxLength: 50 })
|
||||
.filter(s => s.includes('@') && s.includes('.'))
|
||||
.map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`);
|
||||
|
||||
const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 })
|
||||
.filter(s => /^[a-zA-Z0-9_]+$/.test(s));
|
||||
|
||||
const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 })
|
||||
.filter(s => s.trim().length > 0);
|
||||
|
||||
const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 })
|
||||
.filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s));
|
||||
|
||||
const registerRequestArb = fc.record({
|
||||
username: validUsernameArb,
|
||||
email: validEmailArb,
|
||||
nickname: validNicknameArb,
|
||||
password: validPasswordArb,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// 创建模拟服务
|
||||
const mockLoginCoreService = {
|
||||
register: jest.fn(),
|
||||
deleteUser: jest.fn(),
|
||||
generateTokenPair: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountService = {
|
||||
initializeAdminClient: jest.fn(),
|
||||
createZulipAccount: jest.fn(),
|
||||
linkGameAccount: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountsService = {
|
||||
findByGameUserId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
deleteByGameUserId: jest.fn(),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
storeApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LoginService,
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
{
|
||||
provide: ZulipAccountService,
|
||||
useValue: mockZulipAccountService,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: mockZulipAccountsService,
|
||||
},
|
||||
{
|
||||
provide: ApiKeySecurityService,
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
sign: jest.fn().mockReturnValue('mock_jwt_token'),
|
||||
signAsync: jest.fn().mockResolvedValue('mock_jwt_token'),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case 'JWT_SECRET':
|
||||
return 'test_jwt_secret_key_for_testing';
|
||||
case 'JWT_EXPIRES_IN':
|
||||
return '7d';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'UsersService',
|
||||
useValue: {
|
||||
findById: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
findByEmail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
loginService = module.get<LoginService>(LoginService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
zulipAccountService = module.get(ZulipAccountService);
|
||||
zulipAccountsService = module.get('ZulipAccountsService');
|
||||
apiKeySecurityService = module.get(ApiKeySecurityService);
|
||||
|
||||
// 设置默认的mock返回值
|
||||
const mockTokenPair = {
|
||||
access_token: 'mock_access_token',
|
||||
refresh_token: 'mock_refresh_token',
|
||||
expires_in: 604800,
|
||||
token_type: 'Bearer'
|
||||
};
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
|
||||
// Mock LoginService 的 initializeZulipAdminClient 方法
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
|
||||
// 设置环境变量模拟
|
||||
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
|
||||
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
|
||||
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// 清理环境变量
|
||||
delete process.env.ZULIP_SERVER_URL;
|
||||
delete process.env.ZULIP_BOT_EMAIL;
|
||||
delete process.env.ZULIP_BOT_API_KEY;
|
||||
});
|
||||
|
||||
/**
|
||||
* 属性 13: Zulip账号创建一致性
|
||||
*
|
||||
* 验证需求: 账号创建成功率和数据一致性
|
||||
*
|
||||
* 测试内容:
|
||||
* 1. 成功注册时,游戏账号和Zulip账号都应该被创建
|
||||
* 2. 账号关联信息应该正确存储
|
||||
* 3. Zulip账号创建失败时,游戏账号应该被回滚
|
||||
* 4. 数据一致性:邮箱、昵称等信息应该保持一致
|
||||
*/
|
||||
describe('属性 13: Zulip账号创建一致性', () => {
|
||||
it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
const mockZulipResult = {
|
||||
success: true,
|
||||
userId: Math.floor(Math.random() * 1000000),
|
||||
email: registerRequest.email,
|
||||
apiKey: 'zulip_api_key_' + Math.random().toString(36),
|
||||
};
|
||||
|
||||
const mockZulipAccount = {
|
||||
id: mockGameUser.id.toString(),
|
||||
gameUserId: mockGameUser.id.toString(),
|
||||
zulipUserId: mockZulipResult.userId,
|
||||
zulipEmail: mockZulipResult.email,
|
||||
zulipFullName: registerRequest.nickname,
|
||||
zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey,
|
||||
status: 'active' as const,
|
||||
retryCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 设置模拟行为
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||
zulipAccountsService.create.mockResolvedValue(mockZulipAccount);
|
||||
zulipAccountService.linkGameAccount.mockResolvedValue(true);
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||
expect(result.data?.user.email).toBe(registerRequest.email);
|
||||
expect(result.data?.user.nickname).toBe(registerRequest.nickname);
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
|
||||
// 验证Zulip管理员客户端初始化
|
||||
expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled();
|
||||
|
||||
// 验证游戏用户注册
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证Zulip账号创建
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: registerRequest.email,
|
||||
fullName: registerRequest.nickname,
|
||||
password: registerRequest.password,
|
||||
});
|
||||
|
||||
// 验证API Key存储
|
||||
expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith(
|
||||
mockGameUser.id.toString(),
|
||||
mockZulipResult.apiKey
|
||||
);
|
||||
|
||||
// 验证账号关联创建
|
||||
expect(zulipAccountsService.create).toHaveBeenCalledWith({
|
||||
gameUserId: mockGameUser.id.toString(),
|
||||
zulipUserId: mockZulipResult.userId,
|
||||
zulipEmail: mockZulipResult.email,
|
||||
zulipFullName: registerRequest.nickname,
|
||||
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 验证内存关联
|
||||
expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith(
|
||||
mockGameUser.id.toString(),
|
||||
mockZulipResult.userId,
|
||||
mockZulipResult.email,
|
||||
mockZulipResult.apiKey
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在Zulip账号创建失败时回滚游戏账号', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
// 设置模拟行为 - Zulip账号创建失败
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Zulip服务器连接失败',
|
||||
errorCode: 'CONNECTION_FAILED',
|
||||
});
|
||||
loginCoreService.deleteUser.mockResolvedValue(true);
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该失败
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip账号创建失败');
|
||||
|
||||
// 验证游戏用户被创建
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证Zulip账号创建尝试
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: registerRequest.email,
|
||||
fullName: registerRequest.nickname,
|
||||
password: registerRequest.password,
|
||||
});
|
||||
|
||||
// 验证游戏用户被回滚删除
|
||||
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id);
|
||||
|
||||
// 验证没有创建账号关联
|
||||
expect(zulipAccountsService.create).not.toHaveBeenCalled();
|
||||
expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理已存在Zulip账号关联的情况', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
const existingZulipAccount = {
|
||||
id: Math.floor(Math.random() * 1000000).toString(),
|
||||
gameUserId: mockGameUser.id.toString(),
|
||||
zulipUserId: 12345,
|
||||
zulipEmail: registerRequest.email,
|
||||
zulipFullName: registerRequest.nickname,
|
||||
zulipApiKeyEncrypted: 'existing_encrypted_key',
|
||||
status: 'active' as const,
|
||||
retryCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 设置模拟行为 - 已存在Zulip账号关联
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(existingZulipAccount);
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该成功
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||
|
||||
// 验证游戏用户被创建
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证检查了现有关联
|
||||
expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id.toString());
|
||||
|
||||
// 验证没有尝试创建新的Zulip账号
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
expect(zulipAccountsService.create).not.toHaveBeenCalled();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理缺少邮箱或密码的注册请求', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: validUsernameArb,
|
||||
nickname: validNicknameArb,
|
||||
email: fc.option(validEmailArb, { nil: undefined }),
|
||||
password: fc.option(validPasswordArb, { nil: undefined }),
|
||||
}),
|
||||
async (registerRequest) => {
|
||||
// 只测试缺少邮箱或密码的情况
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
return; // 跳过完整数据的情况
|
||||
}
|
||||
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email || null,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: registerRequest.password ? 'hashed_password' : null,
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
// 设置模拟行为
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest as RegisterRequest);
|
||||
|
||||
// 验证结果 - 注册应该成功,但跳过Zulip账号创建
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe(registerRequest.username);
|
||||
expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息
|
||||
|
||||
// 验证游戏用户被创建
|
||||
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
|
||||
|
||||
// 验证没有尝试创建Zulip账号
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
expect(zulipAccountsService.create).not.toHaveBeenCalled();
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理Zulip管理员客户端初始化失败', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 设置模拟行为 - 管理员客户端初始化失败
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip管理员客户端初始化失败'));
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该失败
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip管理员客户端初始化失败');
|
||||
|
||||
// 验证没有尝试创建游戏用户
|
||||
expect(loginCoreService.register).not.toHaveBeenCalled();
|
||||
|
||||
// 验证没有尝试创建Zulip账号
|
||||
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
|
||||
|
||||
// 恢复 mock
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
}),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it('应该正确处理环境变量缺失的情况', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 清除环境变量
|
||||
delete process.env.ZULIP_SERVER_URL;
|
||||
delete process.env.ZULIP_BOT_EMAIL;
|
||||
delete process.env.ZULIP_BOT_API_KEY;
|
||||
|
||||
// 重新设置 mock 以模拟环境变量缺失的错误
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
|
||||
.mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'));
|
||||
|
||||
// 执行注册
|
||||
const result = await loginService.register(registerRequest);
|
||||
|
||||
// 验证结果 - 注册应该失败
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Zulip管理员配置不完整');
|
||||
|
||||
// 验证没有尝试创建游戏用户
|
||||
expect(loginCoreService.register).not.toHaveBeenCalled();
|
||||
|
||||
// 恢复环境变量和 mock
|
||||
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
|
||||
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
|
||||
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
|
||||
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
|
||||
}),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 数据一致性验证测试
|
||||
*
|
||||
* 验证游戏账号和Zulip账号之间的数据一致性
|
||||
*/
|
||||
describe('数据一致性验证', () => {
|
||||
it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
|
||||
// 准备测试数据
|
||||
const mockGameUser: Users = {
|
||||
id: BigInt(Math.floor(Math.random() * 1000000)),
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
nickname: registerRequest.nickname,
|
||||
password_hash: 'hashed_password',
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as Users;
|
||||
|
||||
const mockZulipResult = {
|
||||
success: true,
|
||||
userId: Math.floor(Math.random() * 1000000),
|
||||
email: registerRequest.email,
|
||||
apiKey: 'zulip_api_key_' + Math.random().toString(36),
|
||||
};
|
||||
|
||||
// 设置模拟行为
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockGameUser,
|
||||
isNewUser: true,
|
||||
});
|
||||
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult);
|
||||
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
|
||||
zulipAccountsService.create.mockResolvedValue({} as any);
|
||||
zulipAccountService.linkGameAccount.mockResolvedValue(true);
|
||||
|
||||
// 执行注册
|
||||
await loginService.register(registerRequest);
|
||||
|
||||
// 验证Zulip账号创建时使用了正确的数据
|
||||
expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({
|
||||
email: registerRequest.email, // 相同的邮箱
|
||||
fullName: registerRequest.nickname, // 相同的昵称
|
||||
password: registerRequest.password, // 相同的密码
|
||||
});
|
||||
|
||||
// 验证账号关联存储了正确的数据
|
||||
expect(zulipAccountsService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameUserId: mockGameUser.id.toString(),
|
||||
zulipUserId: mockZulipResult.userId,
|
||||
zulipEmail: registerRequest.email, // 相同的邮箱
|
||||
zulipFullName: registerRequest.nickname, // 相同的昵称
|
||||
zulipApiKeyEncrypted: 'stored_in_redis',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* RegisterService 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试用户注册相关的业务逻辑
|
||||
* - 验证邮箱验证功能
|
||||
* - 测试Zulip账号集成
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-15: 代码规范优化 - 清理未使用的变量apiKeySecurityService (修改者: moyin)
|
||||
* - 2026-01-12: 代码分离 - 从login.service.spec.ts中分离注册相关测试
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-12
|
||||
* @lastModified 2026-01-15
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RegisterService } from './register.service';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||
|
||||
describe('RegisterService', () => {
|
||||
let service: RegisterService;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||
|
||||
const mockUser = {
|
||||
id: BigInt(1),
|
||||
username: 'testuser',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone: null,
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
password_hash: 'hashed_password',
|
||||
github_id: null,
|
||||
is_active: true,
|
||||
last_login_at: null,
|
||||
email_verified: false,
|
||||
phone_verified: false,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockLoginCoreService = {
|
||||
register: jest.fn(),
|
||||
sendEmailVerification: jest.fn(),
|
||||
verifyEmailCode: jest.fn(),
|
||||
resendEmailVerification: jest.fn(),
|
||||
deleteUser: jest.fn(),
|
||||
generateTokenPair: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountService = {
|
||||
initializeAdminClient: jest.fn(),
|
||||
createZulipAccount: jest.fn(),
|
||||
linkGameAccount: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipAccountsService = {
|
||||
findByGameUserId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
deleteByGameUserId: jest.fn(),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
storeApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RegisterService,
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
{
|
||||
provide: ZulipAccountService,
|
||||
useValue: mockZulipAccountService,
|
||||
},
|
||||
{
|
||||
provide: 'ZulipAccountsService',
|
||||
useValue: mockZulipAccountsService,
|
||||
},
|
||||
{
|
||||
provide: ApiKeySecurityService,
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RegisterService>(RegisterService);
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
zulipAccountService = module.get(ZulipAccountService);
|
||||
|
||||
// 设置默认的mock返回值
|
||||
const mockTokenPair = {
|
||||
access_token: 'mock_access_token',
|
||||
refresh_token: 'mock_refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair);
|
||||
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
|
||||
zulipAccountService.createZulipAccount.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 123,
|
||||
email: 'test@example.com',
|
||||
apiKey: 'mock_api_key',
|
||||
isExistingUser: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should handle user registration successfully', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
expect(loginCoreService.register).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle registration failure', async () => {
|
||||
loginCoreService.register.mockRejectedValue(new Error('Registration failed'));
|
||||
|
||||
const result = await service.register({
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
nickname: 'Test User',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Registration failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmailVerification', () => {
|
||||
it('should handle sendEmailVerification in test mode', async () => {
|
||||
loginCoreService.sendEmailVerification.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendEmailVerification('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false); // Test mode returns false
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.data?.is_test_mode).toBe(true);
|
||||
expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
|
||||
it('should handle sendEmailVerification in production mode', async () => {
|
||||
loginCoreService.sendEmailVerification.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: false
|
||||
});
|
||||
|
||||
const result = await service.sendEmailVerification('test@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.is_test_mode).toBe(false);
|
||||
expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyEmailCode', () => {
|
||||
it('should handle verifyEmailCode successfully', async () => {
|
||||
loginCoreService.verifyEmailCode.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verifyEmailCode('test@example.com', '123456');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('邮箱验证成功');
|
||||
expect(loginCoreService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456');
|
||||
});
|
||||
|
||||
it('should handle invalid verification code', async () => {
|
||||
loginCoreService.verifyEmailCode.mockResolvedValue(false);
|
||||
|
||||
const result = await service.verifyEmailCode('test@example.com', '123456');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('验证码错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendEmailVerification', () => {
|
||||
it('should handle resendEmailVerification successfully', async () => {
|
||||
loginCoreService.resendEmailVerification.mockResolvedValue({
|
||||
code: '654321',
|
||||
isTestMode: false
|
||||
});
|
||||
|
||||
const result = await service.resendEmailVerification('test@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.is_test_mode).toBe(false);
|
||||
expect(loginCoreService.resendEmailVerification).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,584 +0,0 @@
|
||||
/**
|
||||
* 注册业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理用户注册相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的注册功能
|
||||
* - 处理业务规则、数据格式化和错误处理
|
||||
* - 集成Zulip账号创建和关联
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于注册业务流程和规则实现
|
||||
* - 调用核心服务完成具体功能
|
||||
* - 为控制器层提供注册业务接口
|
||||
* - 处理注册相关的邮箱验证和Zulip集成
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-15: 代码规范优化 - 清理未使用的导入TokenPair,增强userId非空验证 (修改者: moyin)
|
||||
* - 2026-01-12: 代码分离 - 从login.service.ts中分离注册相关业务逻辑
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-12
|
||||
* @lastModified 2026-01-15
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { LoginCoreService, RegisterRequest } 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 { 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 = {
|
||||
REGISTER_FAILED: 'REGISTER_FAILED',
|
||||
SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED',
|
||||
EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED',
|
||||
RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED',
|
||||
TEST_MODE_ONLY: 'TEST_MODE_ONLY',
|
||||
INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE',
|
||||
} as const;
|
||||
|
||||
const MESSAGES = {
|
||||
REGISTER_SUCCESS: '注册成功',
|
||||
REGISTER_SUCCESS_WITH_ZULIP: '注册成功,Zulip账号已同步创建',
|
||||
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
|
||||
CODE_SENT: '验证码已发送,请查收',
|
||||
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
|
||||
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
|
||||
VERIFICATION_CODE_ERROR: '验证码错误',
|
||||
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 注册响应数据接口
|
||||
*/
|
||||
export interface RegisterResponse {
|
||||
/** 用户信息 */
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
/** 访问令牌 */
|
||||
access_token: string;
|
||||
/** 刷新令牌 */
|
||||
refresh_token: string;
|
||||
/** 访问令牌过期时间(秒) */
|
||||
expires_in: number;
|
||||
/** 令牌类型 */
|
||||
token_type: string;
|
||||
/** 是否为新用户 */
|
||||
is_new_user?: boolean;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用响应接口
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 响应数据 */
|
||||
data?: T;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
/** 错误代码 */
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RegisterService {
|
||||
private readonly logger = new Logger(RegisterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly zulipAccountService: ZulipAccountService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerRequest 注册请求
|
||||
* @returns 注册响应
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<RegisterResponse>> {
|
||||
const startTime = Date.now();
|
||||
const operationId = `register_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
try {
|
||||
this.logger.log(`开始用户注册流程`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 1. 初始化Zulip管理员客户端
|
||||
await this.initializeZulipAdminClient();
|
||||
|
||||
// 2. 调用核心服务进行注册
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
// 3. 创建Zulip账号(使用相同的邮箱和密码)
|
||||
let zulipAccountCreated = false;
|
||||
|
||||
if (registerRequest.email && registerRequest.password) {
|
||||
try {
|
||||
await this.createZulipAccountForUser(authResult.user, registerRequest.password);
|
||||
zulipAccountCreated = true;
|
||||
|
||||
this.logger.log(`Zulip账号创建成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
} catch (zulipError) {
|
||||
const err = zulipError as Error;
|
||||
this.logger.error(`Zulip账号创建失败,开始回滚`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
}, err.stack);
|
||||
|
||||
// 回滚游戏用户注册
|
||||
try {
|
||||
await this.loginCoreService.deleteUser(authResult.user.id);
|
||||
this.logger.log(`游戏用户注册回滚成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
});
|
||||
} catch (rollbackError) {
|
||||
const rollbackErr = rollbackError as Error;
|
||||
this.logger.error(`游戏用户注册回滚失败`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
rollbackError: rollbackErr.message,
|
||||
}, rollbackErr.stack);
|
||||
}
|
||||
|
||||
// 抛出原始错误
|
||||
throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.log(`跳过Zulip账号创建:缺少邮箱或密码`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
hasEmail: !!registerRequest.email,
|
||||
hasPassword: !!registerRequest.password,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 生成JWT令牌对
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 5. 格式化响应数据
|
||||
const response: RegisterResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: true,
|
||||
message: zulipAccountCreated ? MESSAGES.REGISTER_SUCCESS_WITH_ZULIP : MESSAGES.REGISTER_SUCCESS
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`用户注册成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
email: authResult.user.email,
|
||||
zulipAccountCreated,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: response.message
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error(`用户注册失败`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: err.message || '注册失败',
|
||||
error_code: ERROR_CODES.REGISTER_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已发送: ${email}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param code 验证码
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async verifyEmailCode(email: string, code: string): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`验证邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务验证验证码
|
||||
const isValid = await this.loginCoreService.verifyEmailCode(email, code);
|
||||
|
||||
if (isValid) {
|
||||
this.logger.log(`邮箱验证成功: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: MESSAGES.EMAIL_VERIFICATION_SUCCESS
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: MESSAGES.VERIFICATION_CODE_ERROR,
|
||||
error_code: ERROR_CODES.INVALID_VERIFICATION_CODE
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '邮箱验证失败',
|
||||
error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`重新发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务重新发送验证码
|
||||
const result = await this.loginCoreService.resendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已重新发送: ${email}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '重新发送验证码失败',
|
||||
error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户信息
|
||||
*/
|
||||
private formatUserInfo(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(), // 将bigint转换为字符串
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar_url: user.avatar_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理测试模式响应
|
||||
*
|
||||
* @param result 核心服务返回的结果
|
||||
* @param successMessage 成功时的消息
|
||||
* @param emailMessage 邮件发送成功时的消息
|
||||
* @returns 格式化的响应
|
||||
* @private
|
||||
*/
|
||||
private handleTestModeResponse(
|
||||
result: { code: string; isTestMode: boolean },
|
||||
successMessage: string,
|
||||
emailMessage?: string
|
||||
): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> {
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: emailMessage || successMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Zulip管理员客户端
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用环境变量中的管理员凭证初始化Zulip客户端
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 从环境变量获取管理员配置
|
||||
* 2. 验证配置完整性
|
||||
* 3. 初始化ZulipAccountService的管理员客户端
|
||||
*
|
||||
* @throws Error 当配置缺失或初始化失败时
|
||||
* @private
|
||||
*/
|
||||
private async initializeZulipAdminClient(): Promise<void> {
|
||||
try {
|
||||
// 从环境变量获取管理员配置
|
||||
const adminConfig = {
|
||||
realm: process.env.ZULIP_SERVER_URL || process.env.ZULIP_REALM || '',
|
||||
username: process.env.ZULIP_BOT_EMAIL || process.env.ZULIP_ADMIN_EMAIL || '',
|
||||
apiKey: process.env.ZULIP_BOT_API_KEY || process.env.ZULIP_ADMIN_API_KEY || '',
|
||||
};
|
||||
|
||||
// 验证配置完整性
|
||||
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
|
||||
throw new Error('Zulip管理员配置不完整,请检查环境变量');
|
||||
}
|
||||
|
||||
// 初始化管理员客户端
|
||||
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
|
||||
|
||||
if (!initialized) {
|
||||
throw new Error('Zulip管理员客户端初始化失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Zulip管理员客户端初始化失败', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户创建或绑定Zulip账号
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新注册的游戏用户创建对应的Zulip账号或绑定已有账号并建立关联
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 检查是否已存在Zulip账号关联
|
||||
* 2. 尝试创建Zulip账号(如果已存在则自动绑定)
|
||||
* 3. 获取或生成API Key并存储到Redis
|
||||
* 4. 在数据库中创建关联记录
|
||||
* 5. 建立内存关联(用于当前会话)
|
||||
*
|
||||
* @param gameUser 游戏用户信息
|
||||
* @param password 用户密码(明文)
|
||||
* @throws Error 当Zulip账号创建/绑定失败时
|
||||
* @private
|
||||
*/
|
||||
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始为用户创建或绑定Zulip账号', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
nickname: gameUser.nickname,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 检查是否已存在Zulip账号关联
|
||||
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString());
|
||||
if (existingAccount) {
|
||||
this.logger.warn('用户已存在Zulip账号关联,跳过创建', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
existingZulipUserId: existingAccount.zulipUserId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 尝试创建或绑定Zulip账号
|
||||
const createResult = await this.zulipAccountService.createZulipAccount({
|
||||
email: gameUser.email,
|
||||
fullName: gameUser.nickname,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (!createResult.success) {
|
||||
throw new Error(createResult.error || 'Zulip账号创建/绑定失败');
|
||||
}
|
||||
|
||||
// 验证必须获取到 userId(数据库字段 NOT NULL)
|
||||
if (createResult.userId === undefined || createResult.userId === null) {
|
||||
throw new Error('Zulip账号创建成功但未能获取用户ID,无法建立关联');
|
||||
}
|
||||
|
||||
// 3. 处理API Key
|
||||
let finalApiKey = createResult.apiKey;
|
||||
|
||||
// 如果是绑定已有账号但没有API Key,尝试重新获取
|
||||
if (createResult.isExistingUser && !finalApiKey) {
|
||||
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
|
||||
createResult.email!,
|
||||
password
|
||||
);
|
||||
|
||||
if (apiKeyResult.success) {
|
||||
finalApiKey = apiKeyResult.apiKey;
|
||||
} else {
|
||||
this.logger.warn('无法获取已有Zulip账号的API Key', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipEmail: createResult.email,
|
||||
error: apiKeyResult.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 存储API Key到Redis
|
||||
if (finalApiKey) {
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
gameUser.id.toString(),
|
||||
finalApiKey
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 在数据库中创建关联记录
|
||||
await this.zulipAccountsService.create({
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId, // 已在上面验证不为 undefined
|
||||
zulipEmail: createResult.email!,
|
||||
zulipFullName: gameUser.nickname,
|
||||
zulipApiKeyEncrypted: finalApiKey ? 'stored_in_redis' : '',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 6. 建立游戏账号与Zulip账号的内存关联(用于当前会话)
|
||||
if (finalApiKey) {
|
||||
await this.zulipAccountService.linkGameAccount(
|
||||
gameUser.id.toString(),
|
||||
createResult.userId, // 已在上面验证不为 undefined
|
||||
createResult.email!,
|
||||
finalApiKey
|
||||
);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('Zulip账号创建/绑定和关联成功', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId,
|
||||
zulipEmail: createResult.email,
|
||||
isExistingUser: createResult.isExistingUser,
|
||||
hasApiKey: !!finalApiKey,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('为用户创建/绑定Zulip账号失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
// 清理可能创建的部分数据
|
||||
try {
|
||||
await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString());
|
||||
} catch (cleanupError) {
|
||||
this.logger.warn('清理Zulip账号关联数据失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
cleanupError: (cleanupError as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
# Chat 聊天业务模块
|
||||
|
||||
Chat 模块是游戏服务器的核心聊天业务层,负责实现游戏内实时聊天功能,包括玩家会话管理、消息过滤、位置追踪和 Zulip 异步同步。该模块通过 SESSION_QUERY_SERVICE 接口向其他业务模块提供会话查询能力。
|
||||
|
||||
## 对外提供的接口
|
||||
|
||||
### ChatService
|
||||
|
||||
#### handlePlayerLogin(request: PlayerLoginRequest): Promise<LoginResponse>
|
||||
处理玩家登录,验证 Token 并创建游戏会话。
|
||||
|
||||
#### handlePlayerLogout(socketId: string, reason?: string): Promise<void>
|
||||
处理玩家登出,清理会话和相关资源。
|
||||
|
||||
#### sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse>
|
||||
发送聊天消息,包含内容过滤、实时广播和 Zulip 异步同步。
|
||||
|
||||
#### updatePlayerPosition(request: PositionUpdateRequest): Promise<boolean>
|
||||
更新玩家在游戏地图中的位置。
|
||||
|
||||
#### getChatHistory(query: object): Promise<object>
|
||||
获取聊天历史记录。
|
||||
|
||||
#### getSession(socketId: string): Promise<GameSession | null>
|
||||
获取指定 WebSocket 连接的会话信息。
|
||||
|
||||
### ChatSessionService (实现 ISessionManagerService)
|
||||
|
||||
#### createSession(socketId, userId, zulipQueueId, username?, initialMap?, initialPosition?): Promise<GameSession>
|
||||
创建新的游戏会话,建立 WebSocket 与用户的映射关系。
|
||||
|
||||
#### getSession(socketId: string): Promise<GameSession | null>
|
||||
获取会话信息并更新最后活动时间。
|
||||
|
||||
#### destroySession(socketId: string): Promise<boolean>
|
||||
销毁会话并清理相关资源。
|
||||
|
||||
#### injectContext(socketId: string, mapId?: string): Promise<ContextInfo>
|
||||
根据玩家位置注入聊天上下文(Stream/Topic)。
|
||||
|
||||
#### updatePlayerPosition(socketId, mapId, x, y): Promise<boolean>
|
||||
更新玩家位置,支持跨地图切换。
|
||||
|
||||
#### getSocketsInMap(mapId: string): Promise<string[]>
|
||||
获取指定地图中的所有在线玩家 Socket。
|
||||
|
||||
#### cleanupExpiredSessions(timeoutMinutes?: number): Promise<object>
|
||||
清理过期会话,返回清理数量和 Zulip 队列 ID 列表。
|
||||
|
||||
### ChatFilterService
|
||||
|
||||
#### validateMessage(userId, content, targetStream, currentMap): Promise<object>
|
||||
综合验证消息,包含频率限制、内容过滤和权限验证。
|
||||
|
||||
#### filterContent(content: string): Promise<ContentFilterResult>
|
||||
过滤消息内容,检测敏感词、重复字符和恶意链接。
|
||||
|
||||
#### checkRateLimit(userId: string): Promise<boolean>
|
||||
检查用户发送消息的频率是否超限。
|
||||
|
||||
#### validatePermission(userId, targetStream, currentMap): Promise<boolean>
|
||||
验证用户是否有权限向目标频道发送消息。
|
||||
|
||||
### ChatCleanupService
|
||||
|
||||
#### triggerCleanup(): Promise<{ cleanedCount: number }>
|
||||
手动触发会话清理,返回清理的会话数量。
|
||||
|
||||
## 使用的项目内部依赖
|
||||
|
||||
### IZulipClientPoolService (来自 core/zulip_core)
|
||||
Zulip 客户端连接池服务,用于创建/销毁用户客户端和发送消息。
|
||||
|
||||
### IApiKeySecurityService (来自 core/zulip_core)
|
||||
API Key 安全服务,用于获取和删除用户的 Zulip API Key。
|
||||
|
||||
### IZulipConfigService (来自 core/zulip_core)
|
||||
Zulip 配置服务,提供地图与 Stream 的映射关系和附近对象查询。
|
||||
|
||||
### IRedisService (来自 core/redis)
|
||||
Redis 缓存服务,用于存储会话数据、地图玩家列表和频率限制计数。
|
||||
|
||||
### LoginCoreService (来自 core/login_core)
|
||||
登录核心服务,用于验证 JWT Token。
|
||||
|
||||
### ISessionManagerService (来自 core/session_core)
|
||||
会话管理接口定义,ChatSessionService 实现此接口供其他模块依赖。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 实时聊天 + 异步同步架构
|
||||
- 🚀 游戏内实时广播:消息直接广播给同地图玩家,延迟极低
|
||||
- 🔄 Zulip 异步同步:消息异步存储到 Zulip,保证持久化
|
||||
- ⚡ 低延迟体验:先广播后同步,不阻塞用户操作
|
||||
|
||||
### 基于位置的聊天上下文
|
||||
- 根据玩家当前地图自动确定 Zulip Stream
|
||||
- 根据玩家位置附近的对象自动确定 Topic
|
||||
- 支持跨地图切换时自动更新聊天频道
|
||||
|
||||
### 会话生命周期管理
|
||||
- 自动清理旧会话,防止重复登录
|
||||
- 定时清理过期会话(默认 30 分钟无活动)
|
||||
- 支持手动触发清理操作
|
||||
|
||||
### 内容安全和频率控制
|
||||
- 敏感词过滤(支持替换和阻止两种模式)
|
||||
- 频率限制(默认 60 秒内最多 10 条消息)
|
||||
- 恶意链接检测和黑名单域名过滤
|
||||
- 重复字符和刷屏检测
|
||||
|
||||
## 潜在风险
|
||||
|
||||
### Redis 连接故障风险
|
||||
- 会话数据存储在 Redis,连接故障会导致会话丢失
|
||||
- 缓解措施:Redis 集群部署、连接重试机制
|
||||
|
||||
### Zulip 同步延迟风险
|
||||
- 异步同步可能导致消息在 Zulip 中延迟出现
|
||||
- 缓解措施:消息队列、重试机制、失败告警
|
||||
|
||||
### 高并发广播性能风险
|
||||
- 同一地图玩家过多时广播性能下降
|
||||
- 缓解措施:分片广播、消息合并、限制单地图人数
|
||||
|
||||
### 会话清理遗漏风险
|
||||
- 定时清理可能遗漏部分过期会话
|
||||
- 缓解措施:多次清理、Redis 过期策略配合
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* 聊天业务模块测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 模块配置验证
|
||||
* - 服务提供者注册
|
||||
* - 接口导出验证
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { ChatCleanupService } from './services/chat_cleanup.service';
|
||||
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
describe('ChatModule', () => {
|
||||
let module: TestingModule;
|
||||
let chatService: ChatService;
|
||||
let sessionService: ChatSessionService;
|
||||
let filterService: ChatFilterService;
|
||||
let cleanupService: ChatCleanupService;
|
||||
|
||||
// Mock依赖
|
||||
const mockZulipClientPool = {
|
||||
createUserClient: jest.fn(),
|
||||
destroyUserClient: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipConfigService = {
|
||||
getStreamByMap: jest.fn().mockReturnValue('Test Stream'),
|
||||
findNearbyObject: jest.fn().mockReturnValue(null),
|
||||
getAllMapIds: jest.fn().mockReturnValue(['novice_village', 'whale_port']),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
getApiKey: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
srem: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
incr: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLoginCoreService = {
|
||||
verifyToken: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatService,
|
||||
ChatSessionService,
|
||||
ChatFilterService,
|
||||
ChatCleanupService,
|
||||
{
|
||||
provide: SESSION_QUERY_SERVICE,
|
||||
useExisting: ChatSessionService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||||
useValue: mockZulipClientPool,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockZulipConfigService,
|
||||
},
|
||||
{
|
||||
provide: 'API_KEY_SECURITY_SERVICE',
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
chatService = module.get<ChatService>(ChatService);
|
||||
sessionService = module.get<ChatSessionService>(ChatSessionService);
|
||||
filterService = module.get<ChatFilterService>(ChatFilterService);
|
||||
cleanupService = module.get<ChatCleanupService>(ChatCleanupService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('模块配置', () => {
|
||||
it('应该成功编译模块', () => {
|
||||
expect(module).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该提供 ChatService', () => {
|
||||
expect(chatService).toBeDefined();
|
||||
expect(chatService).toBeInstanceOf(ChatService);
|
||||
});
|
||||
|
||||
it('应该提供 ChatSessionService', () => {
|
||||
expect(sessionService).toBeDefined();
|
||||
expect(sessionService).toBeInstanceOf(ChatSessionService);
|
||||
});
|
||||
|
||||
it('应该提供 ChatFilterService', () => {
|
||||
expect(filterService).toBeDefined();
|
||||
expect(filterService).toBeInstanceOf(ChatFilterService);
|
||||
});
|
||||
|
||||
it('应该提供 ChatCleanupService', () => {
|
||||
expect(cleanupService).toBeDefined();
|
||||
expect(cleanupService).toBeInstanceOf(ChatCleanupService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('接口导出', () => {
|
||||
it('应该导出 SESSION_QUERY_SERVICE 接口', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(queryService).toBeDefined();
|
||||
});
|
||||
|
||||
it('SESSION_QUERY_SERVICE 应该指向 ChatSessionService', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(queryService).toBe(sessionService);
|
||||
});
|
||||
|
||||
it('SESSION_QUERY_SERVICE 应该实现 ISessionManagerService 接口', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(typeof queryService.createSession).toBe('function');
|
||||
expect(typeof queryService.getSession).toBe('function');
|
||||
expect(typeof queryService.destroySession).toBe('function');
|
||||
expect(typeof queryService.injectContext).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务依赖注入', () => {
|
||||
it('ChatService 应该能够获取所有依赖', () => {
|
||||
expect(chatService).toBeDefined();
|
||||
// 验证私有依赖通过检查服务是否正常工作
|
||||
expect(chatService['sessionService']).toBeDefined();
|
||||
expect(chatService['filterService']).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatSessionService 应该能够获取所有依赖', () => {
|
||||
expect(sessionService).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatFilterService 应该能够获取所有依赖', () => {
|
||||
expect(filterService).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatCleanupService 应该能够获取所有依赖', () => {
|
||||
expect(cleanupService).toBeDefined();
|
||||
expect(cleanupService['sessionService']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务协作', () => {
|
||||
it('ChatService 应该能够调用 ChatSessionService', async () => {
|
||||
mockRedisService.get.mockResolvedValue(null);
|
||||
const session = await chatService.getSession('test_socket');
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('ChatCleanupService 应该能够调用 ChatSessionService', async () => {
|
||||
mockRedisService.smembers.mockResolvedValue([]);
|
||||
const result = await cleanupService.triggerCleanup();
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('模块导出验证', () => {
|
||||
it('所有导出的服务应该可用', () => {
|
||||
// ChatModule 导出的服务
|
||||
expect(chatService).toBeDefined();
|
||||
expect(sessionService).toBeDefined();
|
||||
expect(filterService).toBeDefined();
|
||||
expect(cleanupService).toBeDefined();
|
||||
});
|
||||
|
||||
it('SESSION_QUERY_SERVICE 应该可供其他模块使用', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(queryService).toBeDefined();
|
||||
// 验证接口方法存在
|
||||
expect(queryService.createSession).toBeDefined();
|
||||
expect(queryService.getSession).toBeDefined();
|
||||
expect(queryService.destroySession).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 聊天业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合聊天相关的业务逻辑服务
|
||||
* - 提供会话管理、消息过滤、清理等功能
|
||||
* - 通过 SESSION_QUERY_SERVICE 接口向其他模块提供会话查询能力
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 ZulipCoreModule(核心层)提供Zulip技术服务
|
||||
* - 依赖 RedisModule(核心层)提供缓存服务
|
||||
* - 依赖 LoginCoreModule(核心层)提供Token验证
|
||||
*
|
||||
* 导出接口:
|
||||
* - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.1
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { ChatCleanupService } from './services/chat_cleanup.service';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Zulip核心服务模块
|
||||
ZulipCoreModule,
|
||||
// Redis缓存模块
|
||||
RedisModule,
|
||||
// 登录核心模块
|
||||
LoginCoreModule,
|
||||
],
|
||||
providers: [
|
||||
// 主聊天服务
|
||||
ChatService,
|
||||
// 会话管理服务
|
||||
ChatSessionService,
|
||||
// 消息过滤服务
|
||||
ChatFilterService,
|
||||
// 会话清理服务
|
||||
ChatCleanupService,
|
||||
// 会话查询接口(供其他模块依赖)
|
||||
{
|
||||
provide: SESSION_QUERY_SERVICE,
|
||||
useExisting: ChatSessionService,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
ChatService,
|
||||
ChatSessionService,
|
||||
ChatFilterService,
|
||||
ChatCleanupService,
|
||||
// 导出会话查询接口
|
||||
SESSION_QUERY_SERVICE,
|
||||
],
|
||||
})
|
||||
export class ChatModule {}
|
||||
@@ -1,437 +0,0 @@
|
||||
/**
|
||||
* 聊天业务服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 玩家登录/登出流程
|
||||
* - 聊天消息发送和广播
|
||||
* - 位置更新和会话管理
|
||||
* - Token验证和错误处理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
describe('ChatService', () => {
|
||||
let service: ChatService;
|
||||
let sessionService: jest.Mocked<ChatSessionService>;
|
||||
let filterService: jest.Mocked<ChatFilterService>;
|
||||
let zulipClientPool: any;
|
||||
let apiKeySecurityService: any;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let mockWebSocketGateway: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock依赖
|
||||
const mockSessionService = {
|
||||
createSession: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
destroySession: jest.fn(),
|
||||
updatePlayerPosition: jest.fn(),
|
||||
injectContext: jest.fn(),
|
||||
getSocketsInMap: jest.fn(),
|
||||
};
|
||||
|
||||
const mockFilterService = {
|
||||
validateMessage: jest.fn(),
|
||||
filterContent: jest.fn(),
|
||||
checkRateLimit: jest.fn(),
|
||||
validatePermission: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipClientPool = {
|
||||
createUserClient: jest.fn(),
|
||||
destroyUserClient: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
getApiKey: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLoginCoreService = {
|
||||
verifyToken: jest.fn(),
|
||||
};
|
||||
|
||||
mockWebSocketGateway = {
|
||||
broadcastToMap: jest.fn(),
|
||||
sendToPlayer: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatService,
|
||||
{
|
||||
provide: ChatSessionService,
|
||||
useValue: mockSessionService,
|
||||
},
|
||||
{
|
||||
provide: ChatFilterService,
|
||||
useValue: mockFilterService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||||
useValue: mockZulipClientPool,
|
||||
},
|
||||
{
|
||||
provide: 'API_KEY_SECURITY_SERVICE',
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatService>(ChatService);
|
||||
sessionService = module.get(ChatSessionService);
|
||||
filterService = module.get(ChatFilterService);
|
||||
zulipClientPool = module.get('ZULIP_CLIENT_POOL_SERVICE');
|
||||
apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE');
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
|
||||
// 设置WebSocket网关
|
||||
service.setWebSocketGateway(mockWebSocketGateway);
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该成功设置WebSocket网关', () => {
|
||||
const newGateway = { broadcastToMap: jest.fn(), sendToPlayer: jest.fn() };
|
||||
service.setWebSocketGateway(newGateway);
|
||||
expect(service['websocketGateway']).toBe(newGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePlayerLogin', () => {
|
||||
const validToken = 'valid.jwt.token';
|
||||
const socketId = 'socket_123';
|
||||
|
||||
it('应该成功处理玩家登录', async () => {
|
||||
const userInfo = {
|
||||
sub: 'user_123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
role: 1,
|
||||
type: 'access' as 'access' | 'refresh',
|
||||
};
|
||||
|
||||
loginCoreService.verifyToken.mockResolvedValue(userInfo);
|
||||
sessionService.createSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId: userInfo.sub,
|
||||
username: userInfo.username,
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.userId).toBe(userInfo.sub);
|
||||
expect(result.username).toBe(userInfo.username);
|
||||
expect(loginCoreService.verifyToken).toHaveBeenCalledWith(validToken, 'access');
|
||||
expect(sessionService.createSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空Token', async () => {
|
||||
const result = await service.handlePlayerLogin({ token: '', socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token或socketId不能为空');
|
||||
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token或socketId不能为空');
|
||||
});
|
||||
|
||||
it('应该处理Token验证失败', async () => {
|
||||
loginCoreService.verifyToken.mockResolvedValue(null);
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token验证失败');
|
||||
});
|
||||
|
||||
it('应该处理Token验证异常', async () => {
|
||||
loginCoreService.verifyToken.mockRejectedValue(new Error('Token expired'));
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token验证失败');
|
||||
});
|
||||
|
||||
it('应该处理会话创建失败', async () => {
|
||||
const userInfo = { sub: 'user_123', username: 'testuser', email: 'test@example.com', role: 1, type: 'access' as 'access' | 'refresh' };
|
||||
loginCoreService.verifyToken.mockResolvedValue(userInfo);
|
||||
sessionService.createSession.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('登录失败,请稍后重试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePlayerLogout', () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
|
||||
it('应该成功处理玩家登出', async () => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
zulipClientPool.destroyUserClient.mockResolvedValue(undefined);
|
||||
apiKeySecurityService.deleteApiKey.mockResolvedValue(undefined);
|
||||
sessionService.destroySession.mockResolvedValue(true);
|
||||
|
||||
await service.handlePlayerLogout(socketId, 'manual');
|
||||
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||
expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId);
|
||||
expect(apiKeySecurityService.deleteApiKey).toHaveBeenCalledWith(userId);
|
||||
expect(sessionService.destroySession).toHaveBeenCalledWith(socketId);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在的情况', async () => {
|
||||
sessionService.getSession.mockResolvedValue(null);
|
||||
|
||||
await service.handlePlayerLogout(socketId);
|
||||
|
||||
expect(sessionService.destroySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理Zulip客户端清理失败', async () => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
zulipClientPool.destroyUserClient.mockRejectedValue(new Error('Zulip error'));
|
||||
sessionService.destroySession.mockResolvedValue(true);
|
||||
|
||||
await service.handlePlayerLogout(socketId);
|
||||
|
||||
expect(sessionService.destroySession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理API Key清理失败', async () => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
apiKeySecurityService.deleteApiKey.mockRejectedValue(new Error('Redis error'));
|
||||
sessionService.destroySession.mockResolvedValue(true);
|
||||
|
||||
await service.handlePlayerLogout(socketId);
|
||||
|
||||
expect(sessionService.destroySession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendChatMessage', () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const content = 'Hello, world!';
|
||||
|
||||
beforeEach(() => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
sessionService.injectContext.mockResolvedValue({
|
||||
stream: 'Whale Port',
|
||||
topic: 'General',
|
||||
});
|
||||
filterService.validateMessage.mockResolvedValue({
|
||||
allowed: true,
|
||||
filteredContent: content,
|
||||
});
|
||||
sessionService.getSocketsInMap.mockResolvedValue([socketId, 'socket_456']);
|
||||
apiKeySecurityService.getApiKey.mockResolvedValue({
|
||||
success: true,
|
||||
apiKey: 'test_api_key',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该成功发送聊天消息', async () => {
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||
expect(filterService.validateMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝不存在的会话', async () => {
|
||||
sessionService.getSession.mockResolvedValue(null);
|
||||
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('会话不存在,请重新登录');
|
||||
});
|
||||
|
||||
it('应该拒绝被过滤的消息', async () => {
|
||||
filterService.validateMessage.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: '消息包含敏感词',
|
||||
});
|
||||
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('消息包含敏感词');
|
||||
});
|
||||
|
||||
it('应该处理消息发送异常', async () => {
|
||||
sessionService.getSession.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('消息发送失败,请稍后重试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlayerPosition', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mapId = 'whale_port';
|
||||
const x = 500;
|
||||
const y = 400;
|
||||
|
||||
it('应该成功更新玩家位置', async () => {
|
||||
sessionService.updatePlayerPosition.mockResolvedValue(true);
|
||||
|
||||
const result = await service.updatePlayerPosition({ socketId, mapId, x, y });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(sessionService.updatePlayerPosition).toHaveBeenCalledWith(socketId, mapId, x, y);
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.updatePlayerPosition({ socketId: '', mapId, x, y });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(sessionService.updatePlayerPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空mapId', async () => {
|
||||
const result = await service.updatePlayerPosition({ socketId, mapId: '', x, y });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(sessionService.updatePlayerPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理更新失败', async () => {
|
||||
sessionService.updatePlayerPosition.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.updatePlayerPosition({ socketId, mapId, x, y });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChatHistory', () => {
|
||||
it('应该返回聊天历史', async () => {
|
||||
const result = await service.getChatHistory({ mapId: 'whale_port' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toBeDefined();
|
||||
expect(Array.isArray(result.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该支持分页查询', async () => {
|
||||
const result = await service.getChatHistory({ mapId: 'whale_port', limit: 10, offset: 0 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
const socketId = 'socket_123';
|
||||
|
||||
it('应该返回会话信息', async () => {
|
||||
const mockSession = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
sessionService.getSession.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await service.getSession(socketId);
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
sessionService.getSession.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getSession(socketId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,500 +0,0 @@
|
||||
/**
|
||||
* 聊天业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 实现聊天相关的业务逻辑
|
||||
* - 协调会话管理、消息过滤等子服务
|
||||
* - 实现游戏内实时聊天 + Zulip 异步同步
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 核心优化:
|
||||
* - 🚀 游戏内实时广播:后端直接广播给同区域用户
|
||||
* - 🔄 Zulip异步同步:消息异步存储到Zulip
|
||||
* - ⚡ 低延迟聊天体验
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.4
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import {
|
||||
IZulipClientPoolService,
|
||||
IApiKeySecurityService,
|
||||
} from '../../core/zulip_core/zulip_core.interfaces';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
// ========== 接口定义 ==========
|
||||
|
||||
/**
|
||||
* 聊天消息请求接口
|
||||
*/
|
||||
export interface ChatMessageRequest {
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
/** 消息内容 */
|
||||
content: string;
|
||||
/** 消息范围:local(本地)、global(全局) */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息响应接口
|
||||
*/
|
||||
export interface ChatMessageResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 消息ID(成功时返回) */
|
||||
messageId?: string;
|
||||
/** 错误信息(失败时返回) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家登录请求接口
|
||||
*/
|
||||
export interface PlayerLoginRequest {
|
||||
/** 认证Token */
|
||||
token: string;
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录响应接口
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 会话ID(成功时返回) */
|
||||
sessionId?: string;
|
||||
/** 用户ID(成功时返回) */
|
||||
userId?: string;
|
||||
/** 用户名(成功时返回) */
|
||||
username?: string;
|
||||
/** 当前地图ID(成功时返回) */
|
||||
currentMap?: string;
|
||||
/** 错误信息(失败时返回) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新请求接口
|
||||
*/
|
||||
export interface PositionUpdateRequest {
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
/** X坐标 */
|
||||
x: number;
|
||||
/** Y坐标 */
|
||||
y: number;
|
||||
/** 地图ID */
|
||||
mapId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏聊天消息格式(用于WebSocket广播)
|
||||
*/
|
||||
interface GameChatMessage {
|
||||
/** 消息类型标识 */
|
||||
t: 'chat_render';
|
||||
/** 发送者用户名 */
|
||||
from: string;
|
||||
/** 消息文本内容 */
|
||||
txt: string;
|
||||
/** 是否显示气泡 */
|
||||
bubble: boolean;
|
||||
/** 时间戳(ISO格式) */
|
||||
timestamp: string;
|
||||
/** 消息ID */
|
||||
messageId: string;
|
||||
/** 地图ID */
|
||||
mapId: string;
|
||||
/** 消息范围 */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天WebSocket网关接口
|
||||
*/
|
||||
interface IChatWebSocketGateway {
|
||||
/**
|
||||
* 向指定地图广播消息
|
||||
* @param mapId 地图ID
|
||||
* @param data 广播数据
|
||||
* @param excludeId 排除的socketId(可选)
|
||||
*/
|
||||
broadcastToMap(mapId: string, data: any, excludeId?: string): void;
|
||||
/**
|
||||
* 向指定玩家发送消息
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param data 发送数据
|
||||
*/
|
||||
sendToPlayer(socketId: string, data: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天业务服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 处理玩家登录/登出的会话管理
|
||||
* - 协调消息过滤和验证流程
|
||||
* - 实现游戏内实时广播和Zulip异步同步
|
||||
*
|
||||
* 主要方法:
|
||||
* - handlePlayerLogin() - 处理玩家登录认证和会话创建
|
||||
* - handlePlayerLogout() - 处理玩家登出和资源清理
|
||||
* - sendChatMessage() - 发送聊天消息并广播
|
||||
* - updatePlayerPosition() - 更新玩家位置信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - 游戏客户端通过WebSocket连接后的聊天功能
|
||||
* - 需要实时广播和持久化存储的聊天场景
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly logger = new Logger(ChatService.name);
|
||||
private readonly DEFAULT_MAP = 'whale_port';
|
||||
private readonly DEFAULT_POSITION = { x: 400, y: 300 };
|
||||
private readonly DEFAULT_PAGE_SIZE = 50;
|
||||
private readonly HISTORY_TIME_OFFSET_MS = 3600000; // 1小时
|
||||
private websocketGateway: IChatWebSocketGateway;
|
||||
|
||||
constructor(
|
||||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
||||
private readonly zulipClientPool: IZulipClientPoolService,
|
||||
private readonly sessionService: ChatSessionService,
|
||||
private readonly filterService: ChatFilterService,
|
||||
@Inject('API_KEY_SECURITY_SERVICE')
|
||||
private readonly apiKeySecurityService: IApiKeySecurityService,
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
) {
|
||||
this.logger.log('ChatService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置WebSocket网关引用
|
||||
* @param gateway WebSocket网关实例
|
||||
*/
|
||||
setWebSocketGateway(gateway: IChatWebSocketGateway): void {
|
||||
this.websocketGateway = gateway;
|
||||
this.logger.log('WebSocket网关引用设置完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理玩家登录
|
||||
* @param request 登录请求,包含token和socketId
|
||||
* @returns 登录响应,包含会话信息或错误信息
|
||||
*/
|
||||
async handlePlayerLogin(request: PlayerLoginRequest): Promise<LoginResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始处理玩家登录', {
|
||||
operation: 'handlePlayerLogin',
|
||||
socketId: request.socketId,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证参数
|
||||
if (!request.token?.trim() || !request.socketId?.trim()) {
|
||||
return { success: false, error: 'Token或socketId不能为空' };
|
||||
}
|
||||
|
||||
// 2. 验证Token
|
||||
const userInfo = await this.validateGameToken(request.token);
|
||||
if (!userInfo) {
|
||||
return { success: false, error: 'Token验证失败' };
|
||||
}
|
||||
|
||||
// 3. 创建会话
|
||||
const sessionResult = await this.createUserSession(request.socketId, userInfo);
|
||||
|
||||
this.logger.log('玩家登录成功', {
|
||||
operation: 'handlePlayerLogin',
|
||||
socketId: request.socketId,
|
||||
userId: userInfo.userId,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: sessionResult.sessionId,
|
||||
userId: userInfo.userId,
|
||||
username: userInfo.username,
|
||||
currentMap: sessionResult.currentMap,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('玩家登录失败', { error: err.message });
|
||||
return { success: false, error: '登录失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理玩家登出
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param reason 登出原因:manual(手动)、timeout(超时)、disconnect(断开)
|
||||
*/
|
||||
async handlePlayerLogout(socketId: string, reason: 'manual' | 'timeout' | 'disconnect' = 'manual'): Promise<void> {
|
||||
this.logger.log('开始处理玩家登出', { socketId, reason });
|
||||
|
||||
try {
|
||||
const session = await this.sessionService.getSession(socketId);
|
||||
if (!session) return;
|
||||
|
||||
const userId = session.userId;
|
||||
|
||||
// 清理Zulip客户端
|
||||
if (userId) {
|
||||
try {
|
||||
await this.zulipClientPool.destroyUserClient(userId);
|
||||
} catch (e) {
|
||||
this.logger.warn('Zulip客户端清理失败', { error: (e as Error).message });
|
||||
}
|
||||
|
||||
// 清理API Key缓存
|
||||
try {
|
||||
await this.apiKeySecurityService.deleteApiKey(userId);
|
||||
} catch (e) {
|
||||
this.logger.warn('API Key缓存清理失败', { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁会话
|
||||
await this.sessionService.destroySession(socketId);
|
||||
|
||||
this.logger.log('玩家登出完成', { socketId, userId, reason });
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('玩家登出失败', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
* @param request 聊天消息请求,包含socketId、content和scope
|
||||
* @returns 发送结果,包含messageId或错误信息
|
||||
*/
|
||||
async sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始处理聊天消息', {
|
||||
operation: 'sendChatMessage',
|
||||
socketId: request.socketId,
|
||||
contentLength: request.content.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 获取会话
|
||||
const session = await this.sessionService.getSession(request.socketId);
|
||||
if (!session) {
|
||||
return { success: false, error: '会话不存在,请重新登录' };
|
||||
}
|
||||
|
||||
// 2. 获取上下文
|
||||
const context = await this.sessionService.injectContext(request.socketId);
|
||||
const targetStream = context.stream;
|
||||
const targetTopic = context.topic || 'General';
|
||||
|
||||
// 3. 消息验证
|
||||
const validationResult = await this.filterService.validateMessage(
|
||||
session.userId,
|
||||
request.content,
|
||||
targetStream,
|
||||
session.currentMap,
|
||||
);
|
||||
|
||||
if (!validationResult.allowed) {
|
||||
return { success: false, error: validationResult.reason || '消息发送失败' };
|
||||
}
|
||||
|
||||
const messageContent = validationResult.filteredContent || request.content;
|
||||
const messageId = `game_${Date.now()}_${session.userId}`;
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId)
|
||||
.catch(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message }));
|
||||
|
||||
// 5. 🔄 异步同步到Zulip
|
||||
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
|
||||
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
||||
|
||||
this.logger.log('聊天消息发送完成', {
|
||||
operation: 'sendChatMessage',
|
||||
messageId,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return { success: true, messageId };
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('聊天消息发送失败', { error: (error as Error).message });
|
||||
return { success: false, error: '消息发送失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新玩家位置
|
||||
* @param request 位置更新请求,包含socketId、坐标和mapId
|
||||
* @returns 更新是否成功
|
||||
*/
|
||||
async updatePlayerPosition(request: PositionUpdateRequest): Promise<boolean> {
|
||||
try {
|
||||
if (!request.socketId?.trim() || !request.mapId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.sessionService.updatePlayerPosition(
|
||||
request.socketId,
|
||||
request.mapId,
|
||||
request.x,
|
||||
request.y,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('更新位置失败', { error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
* @param query 查询参数,包含mapId、limit和offset
|
||||
* @returns 聊天历史记录列表
|
||||
*/
|
||||
async getChatHistory(query: { mapId?: string; limit?: number; offset?: number }) {
|
||||
// 模拟数据,实际应从Zulip获取
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'Player_123',
|
||||
content: '大家好!',
|
||||
scope: 'local',
|
||||
mapId: query.mapId || 'whale_port',
|
||||
timestamp: new Date(Date.now() - this.HISTORY_TIME_OFFSET_MS).toISOString(),
|
||||
streamName: 'Whale Port',
|
||||
topicName: 'Game Chat',
|
||||
},
|
||||
];
|
||||
|
||||
const limit = query.limit || this.DEFAULT_PAGE_SIZE;
|
||||
const offset = query.offset || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: mockMessages.slice(offset, offset + limit),
|
||||
total: mockMessages.length,
|
||||
count: Math.min(mockMessages.length, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
* @param socketId WebSocket连接ID
|
||||
* @returns 会话信息或null
|
||||
*/
|
||||
async getSession(socketId: string) {
|
||||
return this.sessionService.getSession(socketId);
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private async validateGameToken(token: string) {
|
||||
try {
|
||||
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
||||
if (!payload?.sub) return null;
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.username || `user_${payload.sub}`,
|
||||
email: payload.email || `${payload.sub}@example.com`,
|
||||
zulipEmail: undefined,
|
||||
zulipApiKey: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn('Token验证失败', { error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async createUserSession(socketId: string, userInfo: any) {
|
||||
const sessionId = randomUUID();
|
||||
let zulipQueueId = `queue_${sessionId}`;
|
||||
|
||||
// 尝试创建Zulip客户端
|
||||
if (userInfo.zulipApiKey) {
|
||||
try {
|
||||
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, {
|
||||
username: userInfo.zulipEmail || userInfo.email,
|
||||
apiKey: userInfo.zulipApiKey,
|
||||
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
|
||||
});
|
||||
if (clientInstance.queueId) zulipQueueId = clientInstance.queueId;
|
||||
} catch (e) {
|
||||
this.logger.warn('Zulip客户端创建失败', { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const session = await this.sessionService.createSession(
|
||||
socketId,
|
||||
userInfo.userId,
|
||||
zulipQueueId,
|
||||
userInfo.username,
|
||||
this.DEFAULT_MAP,
|
||||
this.DEFAULT_POSITION,
|
||||
);
|
||||
|
||||
return { sessionId, currentMap: session.currentMap };
|
||||
}
|
||||
|
||||
private async broadcastToGamePlayers(mapId: string, message: GameChatMessage, excludeSocketId?: string) {
|
||||
if (!this.websocketGateway) {
|
||||
throw new Error('WebSocket网关未设置');
|
||||
}
|
||||
|
||||
const sockets = await this.sessionService.getSocketsInMap(mapId);
|
||||
const targetSockets = sockets.filter(id => id !== excludeSocketId);
|
||||
|
||||
for (const socketId of targetSockets) {
|
||||
try {
|
||||
this.websocketGateway.sendToPlayer(socketId, message);
|
||||
} catch (e) {
|
||||
this.logger.warn('发送消息失败', { socketId, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncToZulipAsync(userId: string, stream: string, topic: string, content: string, gameMessageId: string) {
|
||||
try {
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
if (!apiKeyResult.success || !apiKeyResult.apiKey) return;
|
||||
|
||||
const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`;
|
||||
await this.zulipClientPool.sendMessage(userId, stream, topic, zulipContent);
|
||||
} catch (error) {
|
||||
this.logger.warn('Zulip同步异常', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* 聊天会话清理服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 定时清理任务启动和停止
|
||||
* - 过期会话清理逻辑
|
||||
* - 手动触发清理功能
|
||||
* - 资源释放和错误处理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatCleanupService } from './chat_cleanup.service';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
describe('ChatCleanupService', () => {
|
||||
let service: ChatCleanupService;
|
||||
let sessionService: jest.Mocked<ChatSessionService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSessionService = {
|
||||
cleanupExpiredSessions: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatCleanupService,
|
||||
{
|
||||
provide: ChatSessionService,
|
||||
useValue: mockSessionService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatCleanupService>(ChatCleanupService);
|
||||
sessionService = module.get(ChatSessionService);
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该在模块初始化时启动清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
const startCleanupTaskSpy = jest.spyOn(service as any, 'startCleanupTask');
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(startCleanupTaskSpy).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在模块销毁时停止清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
const stopCleanupTaskSpy = jest.spyOn(service as any, 'stopCleanupTask');
|
||||
|
||||
await service.onModuleDestroy();
|
||||
|
||||
expect(stopCleanupTaskSpy).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('定时清理任务', () => {
|
||||
it('应该定时执行清理操作', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 5,
|
||||
zulipQueueIds: ['queue_1', 'queue_2'],
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
// 快进5分钟
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在停止任务后不再执行清理', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 0,
|
||||
zulipQueueIds: [],
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
await service.onModuleDestroy();
|
||||
|
||||
sessionService.cleanupExpiredSessions.mockClear();
|
||||
|
||||
// 快进5分钟
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).not.toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerCleanup', () => {
|
||||
it('应该成功执行手动清理', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 3,
|
||||
zulipQueueIds: ['queue_1', 'queue_2', 'queue_3'],
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(3);
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
||||
});
|
||||
|
||||
it('应该处理清理失败', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
await expect(service.triggerCleanup()).rejects.toThrow('Redis error');
|
||||
});
|
||||
|
||||
it('应该返回清理数量为0当没有过期会话', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 0,
|
||||
zulipQueueIds: [],
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清理逻辑', () => {
|
||||
it('应该清理多个过期会话', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 10,
|
||||
zulipQueueIds: Array.from({ length: 10 }, (_, i) => `queue_${i}`),
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理清理过程中的异常', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockRejectedValue(new Error('Cleanup failed'));
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
// 应该记录错误但不抛出异常
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理Zulip队列清理', async () => {
|
||||
jest.useFakeTimers();
|
||||
const zulipQueueIds = ['queue_1', 'queue_2', 'queue_3'];
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 3,
|
||||
zulipQueueIds,
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理空的清理结果', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 0,
|
||||
zulipQueueIds: [],
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理大量过期会话', async () => {
|
||||
const largeCount = 1000;
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: largeCount,
|
||||
zulipQueueIds: Array.from({ length: largeCount }, (_, i) => `queue_${i}`),
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(largeCount);
|
||||
});
|
||||
|
||||
it('应该处理重复启动清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await service.onModuleInit();
|
||||
await service.onModuleInit();
|
||||
|
||||
// 应该只有一个定时器在运行
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理重复停止清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await service.onModuleInit();
|
||||
await service.onModuleDestroy();
|
||||
await service.onModuleDestroy();
|
||||
|
||||
// 不应该抛出异常
|
||||
expect(service['cleanupInterval']).toBeNull();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* 聊天会话清理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定时清理过期会话
|
||||
* - 释放相关资源
|
||||
* - 管理Zulip队列清理
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 移除未使用的依赖 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
/**
|
||||
* 聊天会话清理服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 定时检测和清理过期会话
|
||||
* - 释放Zulip队列等相关资源
|
||||
* - 维护系统资源的健康状态
|
||||
*
|
||||
* 主要方法:
|
||||
* - triggerCleanup() - 手动触发会话清理
|
||||
*
|
||||
* 使用场景:
|
||||
* - 系统启动时自动开始定时清理任务
|
||||
* - 管理员手动触发清理操作
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatCleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ChatCleanupService.name);
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private readonly CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5分钟
|
||||
private readonly SESSION_TIMEOUT_MINUTES = 30;
|
||||
|
||||
constructor(
|
||||
private readonly sessionService: ChatSessionService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('启动会话清理定时任务');
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('停止会话清理定时任务');
|
||||
this.stopCleanupTask();
|
||||
}
|
||||
|
||||
private startCleanupTask() {
|
||||
this.cleanupInterval = setInterval(async () => {
|
||||
await this.performCleanup();
|
||||
}, this.CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopCleanupTask() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performCleanup() {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始执行会话清理');
|
||||
|
||||
try {
|
||||
const result = await this.sessionService.cleanupExpiredSessions(this.SESSION_TIMEOUT_MINUTES);
|
||||
|
||||
// 清理Zulip队列
|
||||
for (const queueId of result.zulipQueueIds) {
|
||||
try {
|
||||
// 这里可以添加Zulip队列清理逻辑
|
||||
this.logger.debug('清理Zulip队列', { queueId });
|
||||
} catch (error) {
|
||||
this.logger.warn('清理Zulip队列失败', { queueId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('会话清理完成', {
|
||||
cleanedCount: result.cleanedCount,
|
||||
zulipQueueCount: result.zulipQueueIds.length,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('会话清理失败', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发清理
|
||||
* @returns 清理结果,包含清理的会话数量
|
||||
*/
|
||||
async triggerCleanup(): Promise<{ cleanedCount: number }> {
|
||||
const result = await this.sessionService.cleanupExpiredSessions(this.SESSION_TIMEOUT_MINUTES);
|
||||
return { cleanedCount: result.cleanedCount };
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* 聊天消息过滤服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 消息内容过滤和敏感词检测
|
||||
* - 频率限制检查
|
||||
* - 权限验证
|
||||
* - 综合消息验证流程
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatFilterService } from './chat_filter.service';
|
||||
|
||||
describe('ChatFilterService', () => {
|
||||
let service: ChatFilterService;
|
||||
let redisService: any;
|
||||
let configManager: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
incr: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigManager = {
|
||||
getStreamByMap: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatFilterService,
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatFilterService>(ChatFilterService);
|
||||
redisService = module.get('REDIS_SERVICE');
|
||||
configManager = module.get('ZULIP_CONFIG_SERVICE');
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterContent', () => {
|
||||
it('应该通过正常消息', async () => {
|
||||
const result = await service.filterContent('Hello, world!');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该拒绝空消息', async () => {
|
||||
const result = await service.filterContent('');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息内容不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝只包含空白字符的消息', async () => {
|
||||
const result = await service.filterContent(' \n\t ');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息内容不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝超长消息', async () => {
|
||||
const longMessage = 'a'.repeat(1001);
|
||||
const result = await service.filterContent(longMessage);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('消息内容过长');
|
||||
});
|
||||
|
||||
it('应该替换敏感词', async () => {
|
||||
const result = await service.filterContent('这是垃圾消息');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toContain('**');
|
||||
});
|
||||
|
||||
it('应该拒绝包含过多重复字符的消息', async () => {
|
||||
const result = await service.filterContent('aaaaa');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含过多重复字符');
|
||||
});
|
||||
|
||||
it('应该拒绝包含重复短语的消息', async () => {
|
||||
const result = await service.filterContent('哈哈哈哈哈');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含过多重复字符');
|
||||
});
|
||||
|
||||
it('应该拒绝包含黑名单链接的消息', async () => {
|
||||
const result = await service.filterContent('访问 https://malware.com 获取更多信息');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含不允许的链接');
|
||||
});
|
||||
|
||||
it('应该允许包含正常链接的消息', async () => {
|
||||
const result = await service.filterContent('访问 https://example.com 获取更多信息');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理多个敏感词', async () => {
|
||||
const result = await service.filterContent('这是垃圾广告');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toContain('**');
|
||||
});
|
||||
|
||||
it('应该处理大小写不敏感的敏感词', async () => {
|
||||
const result = await service.filterContent('这是GARBAGE消息');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
const userId = 'user_123';
|
||||
|
||||
it('应该允许首次发送消息', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该允许在限制内发送消息', async () => {
|
||||
redisService.get.mockResolvedValue('5');
|
||||
redisService.incr.mockResolvedValue(6);
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.incr).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝超过频率限制的消息', async () => {
|
||||
redisService.get.mockResolvedValue('10');
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(redisService.incr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
// 失败时应该允许,避免影响正常用户
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该正确递增计数器', async () => {
|
||||
redisService.get.mockResolvedValue('1');
|
||||
redisService.incr.mockResolvedValue(2);
|
||||
|
||||
await service.checkRateLimit(userId);
|
||||
|
||||
expect(redisService.incr).toHaveBeenCalledWith(`chat:rate_limit:${userId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePermission', () => {
|
||||
const userId = 'user_123';
|
||||
const targetStream = 'Whale Port';
|
||||
const currentMap = 'whale_port';
|
||||
|
||||
it('应该允许有权限的用户发送消息', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
||||
|
||||
const result = await service.validatePermission(userId, targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝无权限的用户发送消息', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Other Stream');
|
||||
|
||||
const result = await service.validatePermission(userId, targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空userId', async () => {
|
||||
const result = await service.validatePermission('', targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空targetStream', async () => {
|
||||
const result = await service.validatePermission(userId, '', currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空currentMap', async () => {
|
||||
const result = await service.validatePermission(userId, targetStream, '');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理地图没有对应Stream的情况', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue(null);
|
||||
|
||||
const result = await service.validatePermission(userId, targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该忽略大小写进行匹配', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('whale port');
|
||||
|
||||
const result = await service.validatePermission(userId, 'WHALE PORT', currentMap);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMessage', () => {
|
||||
const userId = 'user_123';
|
||||
const content = 'Hello, world!';
|
||||
const targetStream = 'Whale Port';
|
||||
const currentMap = 'whale_port';
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
||||
});
|
||||
|
||||
it('应该通过所有验证的消息', async () => {
|
||||
const result = await service.validateMessage(userId, content, targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
// filteredContent可能是undefined(如果没有过滤)或者是过滤后的内容
|
||||
if (result.filteredContent) {
|
||||
expect(result.filteredContent).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('应该拒绝超过频率限制的消息', async () => {
|
||||
redisService.get.mockResolvedValue('10');
|
||||
|
||||
const result = await service.validateMessage(userId, content, targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('发送频率过高');
|
||||
});
|
||||
|
||||
it('应该拒绝包含敏感词的消息', async () => {
|
||||
const result = await service.validateMessage(userId, 'aaaaa', targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该拒绝无权限发送的消息', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Other Stream');
|
||||
|
||||
const result = await service.validateMessage(userId, content, targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('无法向该频道发送消息');
|
||||
});
|
||||
|
||||
it('应该返回过滤后的内容', async () => {
|
||||
const result = await service.validateMessage(userId, '这是垃圾消息', targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filteredContent).toContain('**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理null内容', async () => {
|
||||
const result = await service.filterContent(null as any);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理undefined内容', async () => {
|
||||
const result = await service.filterContent(undefined as any);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理特殊字符', async () => {
|
||||
const result = await service.filterContent('!@#$%^&*()');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理Unicode字符', async () => {
|
||||
const result = await service.filterContent('你好世界 🌍');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理混合语言内容', async () => {
|
||||
const result = await service.filterContent('Hello 世界 مرحبا');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理恰好1000字符的消息', async () => {
|
||||
const message = 'a'.repeat(1000);
|
||||
const result = await service.filterContent(message);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含过多重复字符');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* 聊天消息过滤服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 实施内容审核和频率控制
|
||||
* - 敏感词过滤和权限验证
|
||||
* - 防止恶意操作和滥用
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { IRedisService } from '../../../core/redis/redis.interface';
|
||||
import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||||
|
||||
/**
|
||||
* 内容过滤结果接口
|
||||
*/
|
||||
export interface ContentFilterResult {
|
||||
allowed: boolean;
|
||||
filtered?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感词配置接口
|
||||
*/
|
||||
interface SensitiveWordConfig {
|
||||
word: string;
|
||||
level: 'block' | 'replace';
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息过滤服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 实施消息内容审核和敏感词过滤
|
||||
* - 控制用户发送消息的频率
|
||||
* - 验证用户发送消息的权限
|
||||
*
|
||||
* 主要方法:
|
||||
* - validateMessage() - 综合验证消息(频率+内容+权限)
|
||||
* - filterContent() - 过滤消息内容中的敏感词
|
||||
* - checkRateLimit() - 检查用户发送频率
|
||||
* - validatePermission() - 验证用户发送权限
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户发送聊天消息前的预处理
|
||||
* - 防止恶意刷屏和不当内容传播
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatFilterService {
|
||||
private readonly RATE_LIMIT_PREFIX = 'chat:rate_limit:';
|
||||
private readonly DEFAULT_RATE_LIMIT = 10;
|
||||
private readonly RATE_LIMIT_WINDOW = 60;
|
||||
private readonly MAX_MESSAGE_LENGTH = 1000;
|
||||
private readonly logger = new Logger(ChatFilterService.name);
|
||||
|
||||
private sensitiveWords: SensitiveWordConfig[] = [
|
||||
{ word: '垃圾', level: 'replace', category: 'offensive' },
|
||||
{ word: '广告', level: 'replace', category: 'spam' },
|
||||
{ word: '刷屏', level: 'replace', category: 'spam' },
|
||||
];
|
||||
|
||||
private readonly BLACKLISTED_DOMAINS = ['malware.com', 'phishing.net'];
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_SERVICE')
|
||||
private readonly redisService: IRedisService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('ChatFilterService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合消息验证
|
||||
* @param userId 用户ID
|
||||
* @param content 消息内容
|
||||
* @param targetStream 目标Stream
|
||||
* @param currentMap 当前地图ID
|
||||
* @returns 验证结果,包含是否允许、原因和过滤后的内容
|
||||
*/
|
||||
async validateMessage(
|
||||
userId: string,
|
||||
content: string,
|
||||
targetStream: string,
|
||||
currentMap: string
|
||||
): Promise<{ allowed: boolean; reason?: string; filteredContent?: string }> {
|
||||
// 1. 频率限制检查
|
||||
const rateLimitOk = await this.checkRateLimit(userId);
|
||||
if (!rateLimitOk) {
|
||||
return { allowed: false, reason: '发送频率过高,请稍后重试' };
|
||||
}
|
||||
|
||||
// 2. 内容过滤
|
||||
const contentResult = await this.filterContent(content);
|
||||
if (!contentResult.allowed) {
|
||||
return { allowed: false, reason: contentResult.reason };
|
||||
}
|
||||
|
||||
// 3. 权限验证
|
||||
const permissionOk = await this.validatePermission(userId, targetStream, currentMap);
|
||||
if (!permissionOk) {
|
||||
return { allowed: false, reason: '您当前位置无法向该频道发送消息' };
|
||||
}
|
||||
|
||||
return { allowed: true, filteredContent: contentResult.filtered };
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容过滤
|
||||
* @param content 待过滤的消息内容
|
||||
* @returns 过滤结果,包含是否允许、过滤后内容和原因
|
||||
*/
|
||||
async filterContent(content: string): Promise<ContentFilterResult> {
|
||||
// 空内容检查
|
||||
if (!content?.trim()) {
|
||||
return { allowed: false, reason: '消息内容不能为空' };
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (content.length > this.MAX_MESSAGE_LENGTH) {
|
||||
return { allowed: false, reason: `消息内容过长,最多${this.MAX_MESSAGE_LENGTH}字符` };
|
||||
}
|
||||
|
||||
// 空白字符检查
|
||||
if (/^\s+$/.test(content)) {
|
||||
return { allowed: false, reason: '消息不能只包含空白字符' };
|
||||
}
|
||||
|
||||
// 敏感词检查
|
||||
let filteredContent = content;
|
||||
let hasBlockedWord = false;
|
||||
|
||||
for (const wordConfig of this.sensitiveWords) {
|
||||
if (content.toLowerCase().includes(wordConfig.word.toLowerCase())) {
|
||||
if (wordConfig.level === 'block') {
|
||||
hasBlockedWord = true;
|
||||
break;
|
||||
} else {
|
||||
const replacement = '*'.repeat(wordConfig.word.length);
|
||||
filteredContent = filteredContent.replace(
|
||||
new RegExp(this.escapeRegExp(wordConfig.word), 'gi'),
|
||||
replacement
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockedWord) {
|
||||
return { allowed: false, reason: '消息包含不允许的内容' };
|
||||
}
|
||||
|
||||
// 重复字符检查
|
||||
if (this.hasExcessiveRepetition(content)) {
|
||||
return { allowed: false, reason: '消息包含过多重复字符' };
|
||||
}
|
||||
|
||||
// 恶意链接检查
|
||||
if (!this.checkLinks(content)) {
|
||||
return { allowed: false, reason: '消息包含不允许的链接' };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
filtered: filteredContent !== content ? filteredContent : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 频率限制检查
|
||||
* @param userId 用户ID
|
||||
* @returns 是否通过频率限制检查
|
||||
*/
|
||||
async checkRateLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`;
|
||||
const currentCount = await this.redisService.get(rateLimitKey);
|
||||
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
||||
|
||||
if (count >= this.DEFAULT_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await this.redisService.setex(rateLimitKey, this.RATE_LIMIT_WINDOW, '1');
|
||||
} else {
|
||||
await this.redisService.incr(rateLimitKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('频率检查失败', { error: (error as Error).message });
|
||||
return true; // 失败时允许,避免影响正常用户
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限验证
|
||||
* @param userId 用户ID
|
||||
* @param targetStream 目标Stream
|
||||
* @param currentMap 当前地图ID
|
||||
* @returns 是否有权限发送消息
|
||||
*/
|
||||
async validatePermission(userId: string, targetStream: string, currentMap: string): Promise<boolean> {
|
||||
if (!userId?.trim() || !targetStream?.trim() || !currentMap?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedStream = this.configManager.getStreamByMap(currentMap);
|
||||
if (!allowedStream) return false;
|
||||
|
||||
return targetStream.toLowerCase() === allowedStream.toLowerCase();
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private hasExcessiveRepetition(content: string): boolean {
|
||||
// 连续重复字符检查
|
||||
if (/(.)\1{4,}/.test(content)) return true;
|
||||
|
||||
// 重复短语检查
|
||||
if (/(.{2,})\1{2,}/.test(content)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private checkLinks(content: string): boolean {
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/gi;
|
||||
const urls = content.match(urlPattern);
|
||||
|
||||
if (!urls) return true;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const domain = urlObj.hostname.toLowerCase();
|
||||
|
||||
for (const blacklisted of this.BLACKLISTED_DOMAINS) {
|
||||
if (domain.includes(blacklisted)) return false;
|
||||
}
|
||||
} catch {
|
||||
// URL解析失败,允许通过
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
/**
|
||||
* 聊天会话管理服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 会话创建和销毁
|
||||
* - 位置更新和地图切换
|
||||
* - 上下文注入和Stream/Topic映射
|
||||
* - 过期会话清理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
describe('ChatSessionService', () => {
|
||||
let service: ChatSessionService;
|
||||
let redisService: any;
|
||||
let configManager: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
srem: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigManager = {
|
||||
getStreamByMap: jest.fn(),
|
||||
findNearbyObject: jest.fn(),
|
||||
getAllMapIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatSessionService,
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatSessionService>(ChatSessionService);
|
||||
redisService = module.get('REDIS_SERVICE');
|
||||
configManager = module.get('ZULIP_CONFIG_SERVICE');
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
const username = 'testuser';
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('应该成功创建会话', async () => {
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.socketId).toBe(socketId);
|
||||
expect(session.userId).toBe(userId);
|
||||
expect(session.username).toBe(username);
|
||||
expect(session.zulipQueueId).toBe(zulipQueueId);
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该使用默认地图和位置', async () => {
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId);
|
||||
|
||||
expect(session.currentMap).toBe('novice_village');
|
||||
expect(session.position).toEqual({ x: 400, y: 300 });
|
||||
});
|
||||
|
||||
it('应该使用提供的初始地图和位置', async () => {
|
||||
const initialMap = 'whale_port';
|
||||
const initialPosition = { x: 500, y: 400 };
|
||||
|
||||
const session = await service.createSession(
|
||||
socketId,
|
||||
userId,
|
||||
zulipQueueId,
|
||||
username,
|
||||
initialMap,
|
||||
initialPosition
|
||||
);
|
||||
|
||||
expect(session.currentMap).toBe(initialMap);
|
||||
expect(session.position).toEqual(initialPosition);
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
await expect(service.createSession('', userId, zulipQueueId)).rejects.toThrow('参数不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝空userId', async () => {
|
||||
await expect(service.createSession(socketId, '', zulipQueueId)).rejects.toThrow('参数不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝空zulipQueueId', async () => {
|
||||
await expect(service.createSession(socketId, userId, '')).rejects.toThrow('参数不能为空');
|
||||
});
|
||||
|
||||
it('应该清理旧会话', async () => {
|
||||
const oldSocketId = 'old_socket_123';
|
||||
redisService.get.mockResolvedValueOnce(oldSocketId);
|
||||
redisService.get.mockResolvedValueOnce(JSON.stringify({
|
||||
socketId: oldSocketId,
|
||||
userId,
|
||||
username,
|
||||
zulipQueueId,
|
||||
currentMap: 'novice_village',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(redisService.del).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该添加到地图玩家列表', async () => {
|
||||
await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(redisService.sadd).toHaveBeenCalledWith(
|
||||
expect.stringContaining('chat:map_players:'),
|
||||
socketId
|
||||
);
|
||||
});
|
||||
|
||||
it('应该生成默认用户名', async () => {
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId);
|
||||
|
||||
expect(session.username).toBe(`user_${userId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
it('应该返回会话信息', async () => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
|
||||
const session = await service.getSession(socketId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.socketId).toBe(socketId);
|
||||
expect(session?.userId).toBe(mockSessionData.userId);
|
||||
});
|
||||
|
||||
it('应该更新最后活动时间', async () => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
|
||||
await service.getSession(socketId);
|
||||
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const session = await service.getSession(socketId);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const session = await service.getSession('');
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const session = await service.getSession(socketId);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectContext', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
||||
configManager.findNearbyObject.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('应该返回正确的Stream', async () => {
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.stream).toBe('Whale Port');
|
||||
});
|
||||
|
||||
it('应该使用默认Topic', async () => {
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.topic).toBe('General');
|
||||
});
|
||||
|
||||
it('应该根据附近对象设置Topic', async () => {
|
||||
configManager.findNearbyObject.mockReturnValue({
|
||||
zulipTopic: 'Tavern',
|
||||
});
|
||||
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.topic).toBe('Tavern');
|
||||
});
|
||||
|
||||
it('应该支持指定地图ID', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Market');
|
||||
|
||||
const context = await service.injectContext(socketId, 'market');
|
||||
|
||||
expect(configManager.getStreamByMap).toHaveBeenCalledWith('market');
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.stream).toBe('General');
|
||||
});
|
||||
|
||||
it('应该处理地图没有对应Stream', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue(null);
|
||||
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.stream).toBe('General');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSocketsInMap', () => {
|
||||
const mapId = 'whale_port';
|
||||
|
||||
it('应该返回地图中的所有Socket', async () => {
|
||||
const sockets = ['socket_1', 'socket_2', 'socket_3'];
|
||||
redisService.smembers.mockResolvedValue(sockets);
|
||||
|
||||
const result = await service.getSocketsInMap(mapId);
|
||||
|
||||
expect(result).toEqual(sockets);
|
||||
});
|
||||
|
||||
it('应该处理空地图', async () => {
|
||||
redisService.smembers.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getSocketsInMap(mapId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.smembers.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.getSocketsInMap(mapId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlayerPosition', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mapId = 'whale_port';
|
||||
const x = 500;
|
||||
const y = 400;
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'novice_village',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('应该成功更新位置', async () => {
|
||||
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该更新地图玩家列表当切换地图', async () => {
|
||||
await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(redisService.srem).toHaveBeenCalled();
|
||||
expect(redisService.sadd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该不更新地图玩家列表当在同一地图', async () => {
|
||||
const sameMapData = { ...mockSessionData, currentMap: mapId };
|
||||
redisService.get.mockResolvedValue(JSON.stringify(sameMapData));
|
||||
|
||||
await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(redisService.srem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.updatePlayerPosition('', mapId, x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空mapId', async () => {
|
||||
const result = await service.updatePlayerPosition(socketId, '', x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroySession', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
redisService.del.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('应该成功销毁会话', async () => {
|
||||
const result = await service.destroySession(socketId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.del).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('应该从地图玩家列表移除', async () => {
|
||||
await service.destroySession(socketId);
|
||||
|
||||
expect(redisService.srem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该删除用户会话映射', async () => {
|
||||
await service.destroySession(socketId);
|
||||
|
||||
expect(redisService.del).toHaveBeenCalledWith(
|
||||
expect.stringContaining('chat:user_session:')
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await service.destroySession(socketId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.destroySession('');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.destroySession(socketId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredSessions', () => {
|
||||
beforeEach(() => {
|
||||
configManager.getAllMapIds.mockReturnValue(['novice_village', 'whale_port']);
|
||||
});
|
||||
|
||||
it('应该清理过期会话', async () => {
|
||||
const expiredSession = {
|
||||
socketId: 'socket_123',
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
redisService.smembers.mockResolvedValue(['socket_123']);
|
||||
redisService.get.mockResolvedValueOnce(JSON.stringify(expiredSession));
|
||||
redisService.get.mockResolvedValueOnce(JSON.stringify(expiredSession));
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
redisService.del.mockResolvedValue(1);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBeGreaterThanOrEqual(1);
|
||||
expect(result.zulipQueueIds).toContain('queue_123');
|
||||
});
|
||||
|
||||
it('应该不清理未过期会话', async () => {
|
||||
const activeSession = {
|
||||
socketId: 'socket_123',
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
redisService.smembers.mockResolvedValue(['socket_123']);
|
||||
redisService.get.mockResolvedValue(JSON.stringify(activeSession));
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理多个地图', async () => {
|
||||
redisService.smembers.mockResolvedValue([]);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(redisService.smembers).toHaveBeenCalledTimes(2);
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该使用默认地图当配置为空', async () => {
|
||||
configManager.getAllMapIds.mockReturnValue([]);
|
||||
redisService.smembers.mockResolvedValue([]);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理清理过程中的错误', async () => {
|
||||
redisService.smembers.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
expect(result.zulipQueueIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该清理不存在的会话数据', async () => {
|
||||
redisService.smembers.mockResolvedValue(['socket_123']);
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(redisService.srem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理极大的坐标值', async () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
|
||||
const session = await service.createSession(
|
||||
socketId,
|
||||
userId,
|
||||
zulipQueueId,
|
||||
'testuser',
|
||||
'whale_port',
|
||||
{ x: 999999, y: 999999 }
|
||||
);
|
||||
|
||||
expect(session.position).toEqual({ x: 999999, y: 999999 });
|
||||
});
|
||||
|
||||
it('应该处理负坐标值', async () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
|
||||
const session = await service.createSession(
|
||||
socketId,
|
||||
userId,
|
||||
zulipQueueId,
|
||||
'testuser',
|
||||
'whale_port',
|
||||
{ x: -100, y: -100 }
|
||||
);
|
||||
|
||||
expect(session.position).toEqual({ x: -100, y: -100 });
|
||||
});
|
||||
|
||||
it('应该处理特殊字符的用户名', async () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
const username = 'test@user#123';
|
||||
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(session.username).toBe(username);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,366 +0,0 @@
|
||||
/**
|
||||
* 聊天会话管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 维护WebSocket连接ID与Zulip队列ID的映射关系
|
||||
* - 管理玩家位置跟踪和上下文注入
|
||||
* - 提供空间过滤和会话查询功能
|
||||
* - 实现 ISessionManagerService 接口,供其他模块依赖
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.3
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { IRedisService } from '../../../core/redis/redis.interface';
|
||||
import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||||
import {
|
||||
ISessionManagerService,
|
||||
IPosition,
|
||||
IGameSession,
|
||||
IContextInfo,
|
||||
} from '../../../core/session_core/session_core.interfaces';
|
||||
|
||||
// 常量定义
|
||||
const DEFAULT_MAP_IDS = ['novice_village', 'tavern', 'market'] as const;
|
||||
const SESSION_TIMEOUT = 3600; // 1小时
|
||||
const NEARBY_OBJECT_RADIUS = 50; // 附近对象搜索半径
|
||||
|
||||
/**
|
||||
* 位置信息接口(兼容旧代码)
|
||||
*/
|
||||
export type Position = IPosition;
|
||||
|
||||
/**
|
||||
* 游戏会话接口(兼容旧代码)
|
||||
*/
|
||||
export type GameSession = IGameSession;
|
||||
|
||||
/**
|
||||
* 上下文信息接口(兼容旧代码)
|
||||
*/
|
||||
export type ContextInfo = IContextInfo;
|
||||
|
||||
/**
|
||||
* 聊天会话管理服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 管理WebSocket连接与用户会话的映射
|
||||
* - 跟踪玩家在游戏地图中的位置
|
||||
* - 根据位置注入聊天上下文(Stream/Topic)
|
||||
*
|
||||
* 主要方法:
|
||||
* - createSession() - 创建新的游戏会话
|
||||
* - getSession() - 获取会话信息
|
||||
* - updatePlayerPosition() - 更新玩家位置
|
||||
* - destroySession() - 销毁会话
|
||||
* - injectContext() - 注入聊天上下文
|
||||
*
|
||||
* 使用场景:
|
||||
* - 玩家登录游戏后的会话管理
|
||||
* - 基于位置的聊天频道自动切换
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatSessionService implements ISessionManagerService {
|
||||
private readonly SESSION_PREFIX = 'chat:session:';
|
||||
private readonly MAP_PLAYERS_PREFIX = 'chat:map_players:';
|
||||
private readonly USER_SESSION_PREFIX = 'chat:user_session:';
|
||||
private readonly DEFAULT_MAP = 'novice_village';
|
||||
private readonly DEFAULT_POSITION: Position = { x: 400, y: 300 };
|
||||
private readonly logger = new Logger(ChatSessionService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_SERVICE')
|
||||
private readonly redisService: IRedisService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('ChatSessionService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会话
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param userId 用户ID
|
||||
* @param zulipQueueId Zulip队列ID
|
||||
* @param username 用户名(可选)
|
||||
* @param initialMap 初始地图ID(可选)
|
||||
* @param initialPosition 初始位置(可选)
|
||||
* @returns 创建的游戏会话
|
||||
* @throws Error 参数为空时抛出异常
|
||||
*/
|
||||
async createSession(
|
||||
socketId: string,
|
||||
userId: string,
|
||||
zulipQueueId: string,
|
||||
username?: string,
|
||||
initialMap?: string,
|
||||
initialPosition?: Position,
|
||||
): Promise<GameSession> {
|
||||
this.logger.log('创建游戏会话', { socketId, userId });
|
||||
|
||||
// 参数验证
|
||||
if (!socketId?.trim() || !userId?.trim() || !zulipQueueId?.trim()) {
|
||||
throw new Error('参数不能为空');
|
||||
}
|
||||
|
||||
// 检查并清理旧会话
|
||||
const existingSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${userId}`);
|
||||
if (existingSocketId) {
|
||||
await this.destroySession(existingSocketId);
|
||||
}
|
||||
|
||||
// 创建会话对象
|
||||
const now = new Date();
|
||||
const session: GameSession = {
|
||||
socketId,
|
||||
userId,
|
||||
username: username || `user_${userId}`,
|
||||
zulipQueueId,
|
||||
currentMap: initialMap || this.DEFAULT_MAP,
|
||||
position: initialPosition || { ...this.DEFAULT_POSITION },
|
||||
lastActivity: now,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// 存储到Redis
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
// 添加到地图玩家列表
|
||||
const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`;
|
||||
await this.redisService.sadd(mapKey, socketId);
|
||||
await this.redisService.expire(mapKey, SESSION_TIMEOUT);
|
||||
|
||||
// 建立用户到会话的映射
|
||||
const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`;
|
||||
await this.redisService.setex(userSessionKey, SESSION_TIMEOUT, socketId);
|
||||
|
||||
this.logger.log('会话创建成功', { socketId, userId, currentMap: session.currentMap });
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
* @param socketId WebSocket连接ID
|
||||
* @returns 会话信息或null
|
||||
*/
|
||||
async getSession(socketId: string): Promise<GameSession | null> {
|
||||
if (!socketId?.trim()) return null;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) return null;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
|
||||
// 更新最后活动时间
|
||||
session.lastActivity = new Date();
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
this.logger.error('获取会话失败', { socketId, error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文注入:根据位置确定Stream/Topic
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param mapId 地图ID(可选,默认使用会话当前地图)
|
||||
* @returns 上下文信息,包含stream和topic
|
||||
*/
|
||||
async injectContext(socketId: string, mapId?: string): Promise<ContextInfo> {
|
||||
try {
|
||||
const session = await this.getSession(socketId);
|
||||
if (!session) throw new Error('会话不存在');
|
||||
|
||||
const targetMapId = mapId || session.currentMap;
|
||||
const stream = this.configManager.getStreamByMap(targetMapId) || 'General';
|
||||
|
||||
let topic = 'General';
|
||||
if (session.position) {
|
||||
const nearbyObject = this.configManager.findNearbyObject(
|
||||
targetMapId,
|
||||
session.position.x,
|
||||
session.position.y,
|
||||
NEARBY_OBJECT_RADIUS
|
||||
);
|
||||
if (nearbyObject) topic = nearbyObject.zulipTopic;
|
||||
}
|
||||
|
||||
return { stream, topic };
|
||||
} catch (error) {
|
||||
this.logger.error('上下文注入失败', { socketId, error: (error as Error).message });
|
||||
return { stream: 'General' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定地图的所有Socket
|
||||
* @param mapId 地图ID
|
||||
* @returns Socket ID列表
|
||||
*/
|
||||
async getSocketsInMap(mapId: string): Promise<string[]> {
|
||||
try {
|
||||
const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`;
|
||||
return await this.redisService.smembers(mapKey);
|
||||
} catch (error) {
|
||||
this.logger.error('获取地图玩家失败', { mapId, error: (error as Error).message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新玩家位置
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param mapId 地图ID
|
||||
* @param x X坐标
|
||||
* @param y Y坐标
|
||||
* @returns 更新是否成功
|
||||
*/
|
||||
async updatePlayerPosition(socketId: string, mapId: string, x: number, y: number): Promise<boolean> {
|
||||
if (!socketId?.trim() || !mapId?.trim()) return false;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) return false;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
const oldMapId = session.currentMap;
|
||||
const mapChanged = oldMapId !== mapId;
|
||||
|
||||
// 更新会话
|
||||
session.currentMap = mapId;
|
||||
session.position = { x, y };
|
||||
session.lastActivity = new Date();
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
// 如果切换地图,更新地图玩家列表
|
||||
if (mapChanged) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${oldMapId}`, socketId);
|
||||
const newMapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`;
|
||||
await this.redisService.sadd(newMapKey, socketId);
|
||||
await this.redisService.expire(newMapKey, SESSION_TIMEOUT);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('更新位置失败', { socketId, error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁会话
|
||||
* @param socketId WebSocket连接ID
|
||||
* @returns 销毁是否成功
|
||||
*/
|
||||
async destroySession(socketId: string): Promise<boolean> {
|
||||
if (!socketId?.trim()) return false;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
|
||||
if (!sessionData) return true;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
|
||||
// 从地图玩家列表移除
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${session.currentMap}`, socketId);
|
||||
|
||||
// 删除用户会话映射
|
||||
await this.redisService.del(`${this.USER_SESSION_PREFIX}${session.userId}`);
|
||||
|
||||
// 删除会话数据
|
||||
await this.redisService.del(sessionKey);
|
||||
|
||||
this.logger.log('会话销毁成功', { socketId, userId: session.userId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('销毁会话失败', { socketId, error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期会话
|
||||
* @param timeoutMinutes 超时时间(分钟),默认30分钟
|
||||
* @returns 清理结果,包含清理数量和Zulip队列ID列表
|
||||
*/
|
||||
async cleanupExpiredSessions(timeoutMinutes: number = 30): Promise<{ cleanedCount: number; zulipQueueIds: string[] }> {
|
||||
const expiredSessions: GameSession[] = [];
|
||||
const zulipQueueIds: string[] = [];
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const mapIds = this.configManager.getAllMapIds().length > 0
|
||||
? this.configManager.getAllMapIds()
|
||||
: DEFAULT_MAP_IDS;
|
||||
|
||||
for (const mapId of mapIds) {
|
||||
const socketIds = await this.getSocketsInMap(mapId);
|
||||
|
||||
for (const socketId of socketIds) {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
|
||||
if (!sessionData) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${mapId}`, socketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
const lastActivityTime = session.lastActivity.getTime();
|
||||
|
||||
if (now - lastActivityTime > timeoutMs) {
|
||||
expiredSessions.push(session);
|
||||
zulipQueueIds.push(session.zulipQueueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of expiredSessions) {
|
||||
await this.destroySession(session.socketId);
|
||||
}
|
||||
|
||||
return { cleanedCount: expiredSessions.length, zulipQueueIds };
|
||||
} catch (error) {
|
||||
this.logger.error('清理过期会话失败', { error: (error as Error).message });
|
||||
return { cleanedCount: 0, zulipQueueIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private serializeSession(session: GameSession): string {
|
||||
return JSON.stringify({
|
||||
...session,
|
||||
lastActivity: session.lastActivity.toISOString(),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private deserializeSession(data: string): GameSession {
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
...parsed,
|
||||
lastActivity: new Date(parsed.lastActivity),
|
||||
createdAt: new Date(parsed.createdAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,8 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../auth/current_user.decorator';
|
||||
import { JwtPayload } from '../../../core/login_core/login_core.service';
|
||||
|
||||
// 导入业务服务
|
||||
|
||||
@@ -51,8 +51,8 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, AuthenticatedRequest } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard, AuthenticatedRequest } from '../auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../auth/current_user.decorator';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
|
||||
// 导入业务服务
|
||||
|
||||
@@ -21,22 +21,8 @@
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
import * as WebSocket from 'ws';
|
||||
import { LocationBroadcastGateway } from './location_broadcast.gateway';
|
||||
|
||||
// 扩展的WebSocket接口,与gateway中的定义保持一致,添加测试所需的mock方法
|
||||
interface TestExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
sessionIds?: Set<string>;
|
||||
connectionTimeout?: NodeJS.Timeout;
|
||||
isAlive?: boolean;
|
||||
emit: jest.Mock;
|
||||
to: jest.Mock;
|
||||
join: jest.Mock;
|
||||
leave: jest.Mock;
|
||||
rooms: Set<string>;
|
||||
}
|
||||
import { WebSocketAuthGuard, AuthenticatedSocket } from './websocket_auth.guard';
|
||||
import {
|
||||
JoinSessionMessage,
|
||||
LeaveSessionMessage,
|
||||
@@ -46,27 +32,27 @@ import {
|
||||
import { Position } from '../../core/location_broadcast_core/position.interface';
|
||||
import { SessionUser, SessionUserStatus } from '../../core/location_broadcast_core/session.interface';
|
||||
|
||||
// 模拟原生WebSocket
|
||||
// 模拟Socket.IO
|
||||
const mockSocket = {
|
||||
id: 'socket123',
|
||||
readyState: WebSocket.OPEN,
|
||||
send: jest.fn(),
|
||||
close: jest.fn(),
|
||||
terminate: jest.fn(),
|
||||
ping: jest.fn(),
|
||||
pong: jest.fn(),
|
||||
on: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
sessionIds: new Set<string>(),
|
||||
isAlive: true,
|
||||
handshake: {
|
||||
address: '127.0.0.1',
|
||||
headers: { 'user-agent': 'test-client' },
|
||||
query: { token: 'test_token' },
|
||||
auth: {},
|
||||
},
|
||||
rooms: new Set(['socket123']),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
emit: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const mockServer = {
|
||||
clients: new Set(),
|
||||
on: jest.fn(),
|
||||
use: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
} as any;
|
||||
|
||||
describe('LocationBroadcastGateway', () => {
|
||||
@@ -74,9 +60,6 @@ describe('LocationBroadcastGateway', () => {
|
||||
let mockLocationBroadcastCore: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// 使用假定时器
|
||||
jest.useFakeTimers();
|
||||
|
||||
// 创建模拟的核心服务
|
||||
mockLocationBroadcastCore = {
|
||||
addUserToSession: jest.fn(),
|
||||
@@ -118,48 +101,14 @@ describe('LocationBroadcastGateway', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 清理所有定时器和间隔
|
||||
jest.clearAllTimers();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 清理gateway中的定时器
|
||||
if (gateway) {
|
||||
// 清理心跳间隔
|
||||
const heartbeatInterval = (gateway as any).heartbeatInterval;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
(gateway as any).heartbeatInterval = null;
|
||||
}
|
||||
|
||||
// 清理所有客户端的连接超时
|
||||
const clients = (gateway as any).clients;
|
||||
if (clients) {
|
||||
clients.forEach((client: any) => {
|
||||
if (client.connectionTimeout) {
|
||||
clearTimeout(client.connectionTimeout);
|
||||
client.connectionTimeout = null;
|
||||
}
|
||||
});
|
||||
clients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复真实定时器
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// 确保所有定时器都被清理
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('afterInit', () => {
|
||||
it('应该正确初始化WebSocket服务器', () => {
|
||||
gateway.afterInit(mockServer);
|
||||
|
||||
// 验证初始化完成(主要是确保不抛出异常)
|
||||
expect(true).toBe(true);
|
||||
expect(mockServer.use).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,15 +116,21 @@ describe('LocationBroadcastGateway', () => {
|
||||
it('应该处理客户端连接', () => {
|
||||
gateway.handleConnection(mockSocket);
|
||||
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('welcome')
|
||||
);
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('welcome', expect.objectContaining({
|
||||
type: 'connection_established',
|
||||
message: '连接已建立',
|
||||
socketId: mockSocket.id,
|
||||
}));
|
||||
});
|
||||
|
||||
it('应该设置连接超时', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
gateway.handleConnection(mockSocket);
|
||||
|
||||
expect((mockSocket as any).connectionTimeout).toBeDefined();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,7 +140,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
...mockSocket,
|
||||
userId: 'user123',
|
||||
user: { sub: 'user123', username: 'testuser' },
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
mockLocationBroadcastCore.cleanupUserData.mockResolvedValue(undefined);
|
||||
|
||||
@@ -208,7 +163,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
const authenticatedSocket = {
|
||||
...mockSocket,
|
||||
userId: 'user123',
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
mockLocationBroadcastCore.cleanupUserData.mockRejectedValue(new Error('清理失败'));
|
||||
|
||||
@@ -233,12 +188,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
...mockSocket,
|
||||
userId: 'user123',
|
||||
user: { sub: 'user123', username: 'testuser' },
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
rooms: new Set<string>(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
const mockSessionUsers: SessionUser[] = [
|
||||
{
|
||||
@@ -286,9 +236,16 @@ describe('LocationBroadcastGateway', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('session_joined')
|
||||
expect(mockAuthenticatedSocket.emit).toHaveBeenCalledWith(
|
||||
'session_joined',
|
||||
expect.objectContaining({
|
||||
type: 'session_joined',
|
||||
sessionId: mockJoinMessage.sessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockAuthenticatedSocket.to).toHaveBeenCalledWith(mockJoinMessage.sessionId);
|
||||
expect(mockAuthenticatedSocket.join).toHaveBeenCalledWith(mockJoinMessage.sessionId);
|
||||
});
|
||||
|
||||
it('应该在没有初始位置时成功加入会话', async () => {
|
||||
@@ -302,19 +259,17 @@ describe('LocationBroadcastGateway', () => {
|
||||
await gateway.handleJoinSession(mockAuthenticatedSocket, messageWithoutPosition);
|
||||
|
||||
expect(mockLocationBroadcastCore.setUserPosition).not.toHaveBeenCalled();
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('session_joined')
|
||||
expect(mockAuthenticatedSocket.emit).toHaveBeenCalledWith(
|
||||
'session_joined',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在加入会话失败时发送错误消息', async () => {
|
||||
it('应该在加入会话失败时抛出WebSocket异常', async () => {
|
||||
mockLocationBroadcastCore.addUserToSession.mockRejectedValue(new Error('加入失败'));
|
||||
|
||||
await gateway.handleJoinSession(mockAuthenticatedSocket, mockJoinMessage);
|
||||
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('error')
|
||||
);
|
||||
await expect(gateway.handleJoinSession(mockAuthenticatedSocket, mockJoinMessage))
|
||||
.rejects.toThrow(WsException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,12 +284,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
...mockSocket,
|
||||
userId: 'user123',
|
||||
user: { sub: 'user123', username: 'testuser' },
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
rooms: new Set<string>(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
it('应该成功处理离开会话请求', async () => {
|
||||
mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined);
|
||||
@@ -346,19 +296,22 @@ describe('LocationBroadcastGateway', () => {
|
||||
mockAuthenticatedSocket.userId,
|
||||
);
|
||||
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('leave_session_success')
|
||||
expect(mockAuthenticatedSocket.to).toHaveBeenCalledWith(mockLeaveMessage.sessionId);
|
||||
expect(mockAuthenticatedSocket.leave).toHaveBeenCalledWith(mockLeaveMessage.sessionId);
|
||||
expect(mockAuthenticatedSocket.emit).toHaveBeenCalledWith(
|
||||
'leave_session_success',
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
message: '成功离开会话',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在离开会话失败时发送错误消息', async () => {
|
||||
it('应该在离开会话失败时抛出WebSocket异常', async () => {
|
||||
mockLocationBroadcastCore.removeUserFromSession.mockRejectedValue(new Error('离开失败'));
|
||||
|
||||
await gateway.handleLeaveSession(mockAuthenticatedSocket, mockLeaveMessage);
|
||||
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('error')
|
||||
);
|
||||
await expect(gateway.handleLeaveSession(mockAuthenticatedSocket, mockLeaveMessage))
|
||||
.rejects.toThrow(WsException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,11 +329,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
userId: 'user123',
|
||||
user: { sub: 'user123', username: 'testuser' },
|
||||
rooms: new Set(['socket123', 'session123']), // 用户在会话中
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
it('应该成功处理位置更新请求', async () => {
|
||||
mockLocationBroadcastCore.setUserPosition.mockResolvedValue(undefined);
|
||||
@@ -397,19 +346,21 @@ describe('LocationBroadcastGateway', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('position_update_success')
|
||||
expect(mockAuthenticatedSocket.to).toHaveBeenCalledWith('session123');
|
||||
expect(mockAuthenticatedSocket.emit).toHaveBeenCalledWith(
|
||||
'position_update_success',
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
message: '位置更新成功',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在位置更新失败时发送错误消息', async () => {
|
||||
it('应该在位置更新失败时抛出WebSocket异常', async () => {
|
||||
mockLocationBroadcastCore.setUserPosition.mockRejectedValue(new Error('更新失败'));
|
||||
|
||||
await gateway.handlePositionUpdate(mockAuthenticatedSocket, mockPositionMessage);
|
||||
|
||||
expect(mockAuthenticatedSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('error')
|
||||
);
|
||||
await expect(gateway.handlePositionUpdate(mockAuthenticatedSocket, mockPositionMessage))
|
||||
.rejects.toThrow(WsException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -421,17 +372,26 @@ describe('LocationBroadcastGateway', () => {
|
||||
};
|
||||
|
||||
it('应该成功处理心跳请求', async () => {
|
||||
jest.useFakeTimers();
|
||||
const timeout = setTimeout(() => {}, 1000);
|
||||
(mockSocket as any).connectionTimeout = timeout;
|
||||
|
||||
await gateway.handleHeartbeat(mockSocket, mockHeartbeatMessage);
|
||||
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('heartbeat_response')
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith(
|
||||
'heartbeat_response',
|
||||
expect.objectContaining({
|
||||
type: 'heartbeat_response',
|
||||
clientTimestamp: mockHeartbeatMessage.timestamp,
|
||||
sequence: mockHeartbeatMessage.sequence,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该重置连接超时', async () => {
|
||||
jest.useFakeTimers();
|
||||
const originalTimeout = setTimeout(() => {}, 1000);
|
||||
(mockSocket as any).connectionTimeout = originalTimeout;
|
||||
|
||||
@@ -440,6 +400,8 @@ describe('LocationBroadcastGateway', () => {
|
||||
// 验证新的超时被设置
|
||||
expect((mockSocket as any).connectionTimeout).toBeDefined();
|
||||
expect((mockSocket as any).connectionTimeout).not.toBe(originalTimeout);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理心跳异常而不断开连接', async () => {
|
||||
@@ -463,12 +425,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
userId: 'user123',
|
||||
user: { sub: 'user123', username: 'testuser' },
|
||||
rooms: new Set(['socket123', 'session123', 'session456']),
|
||||
sessionIds: new Set(['session123', 'session456']), // Add this line
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
it('应该清理用户在所有会话中的数据', async () => {
|
||||
mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined);
|
||||
@@ -482,18 +439,38 @@ describe('LocationBroadcastGateway', () => {
|
||||
expect(mockLocationBroadcastCore.cleanupUserData).toHaveBeenCalledWith('user123');
|
||||
});
|
||||
|
||||
it('应该处理清理过程中的错误', async () => {
|
||||
it('应该向会话中其他用户广播离开通知', async () => {
|
||||
mockLocationBroadcastCore.removeUserFromSession.mockResolvedValue(undefined);
|
||||
mockLocationBroadcastCore.cleanupUserData.mockResolvedValue(undefined);
|
||||
|
||||
await (gateway as any).handleUserDisconnection(mockAuthenticatedSocket, 'connection_lost');
|
||||
|
||||
expect(mockAuthenticatedSocket.to).toHaveBeenCalledWith('session123');
|
||||
expect(mockAuthenticatedSocket.to).toHaveBeenCalledWith('session456');
|
||||
});
|
||||
|
||||
it('应该处理部分清理失败的情况', async () => {
|
||||
mockLocationBroadcastCore.removeUserFromSession
|
||||
.mockResolvedValueOnce(undefined) // 第一个会话成功
|
||||
.mockRejectedValueOnce(new Error('移除失败')); // 第二个会话失败
|
||||
mockLocationBroadcastCore.cleanupUserData.mockResolvedValue(undefined);
|
||||
|
||||
// 应该不抛出异常
|
||||
await expect((gateway as any).handleUserDisconnection(mockAuthenticatedSocket, 'connection_lost'))
|
||||
.resolves.toBeUndefined();
|
||||
|
||||
expect(mockLocationBroadcastCore.cleanupUserData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket异常过滤器', () => {
|
||||
it('应该正确格式化WebSocket异常', () => {
|
||||
const exception = new WsException({
|
||||
type: 'error',
|
||||
code: 'TEST_ERROR',
|
||||
message: '测试错误',
|
||||
});
|
||||
|
||||
// 直接测试异常处理逻辑,而不是依赖过滤器类
|
||||
const errorResponse = {
|
||||
type: 'error',
|
||||
@@ -513,12 +490,7 @@ describe('LocationBroadcastGateway', () => {
|
||||
...mockSocket,
|
||||
userId: 'user123',
|
||||
user: { sub: 'user123', username: 'testuser' },
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
rooms: new Set<string>(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
// 1. 用户加入会话
|
||||
const joinMessage: JoinSessionMessage = {
|
||||
@@ -567,22 +539,14 @@ describe('LocationBroadcastGateway', () => {
|
||||
id: 'socket1',
|
||||
userId: 'user1',
|
||||
rooms: new Set(['socket1', 'session123']),
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
const user2Socket = {
|
||||
...mockSocket,
|
||||
id: 'socket2',
|
||||
userId: 'user2',
|
||||
rooms: new Set(['socket2', 'session123']),
|
||||
emit: jest.fn(),
|
||||
to: jest.fn().mockReturnThis(),
|
||||
join: jest.fn(),
|
||||
leave: jest.fn(),
|
||||
} as TestExtendedWebSocket;
|
||||
} as AuthenticatedSocket;
|
||||
|
||||
mockLocationBroadcastCore.setUserPosition.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -14,18 +14,18 @@
|
||||
* - 实时广播:向会话中的其他用户广播位置更新
|
||||
*
|
||||
* 技术实现:
|
||||
* - 原生WebSocket:提供WebSocket通信能力
|
||||
* - Socket.IO:提供WebSocket通信能力
|
||||
* - JWT认证:保护需要认证的WebSocket事件
|
||||
* - 核心服务集成:调用位置广播核心服务处理业务逻辑
|
||||
* - 异常处理:统一的WebSocket异常处理和错误响应
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 重构为原生WebSocket - 移除Socket.IO依赖,使用原生WebSocket (修改者: moyin)
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -39,8 +39,7 @@ import {
|
||||
OnGatewayInit,
|
||||
WsException,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server } from 'ws';
|
||||
import * as WebSocket from 'ws';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Logger, UseFilters, UseGuards, UsePipes, ValidationPipe, ArgumentsHost, Inject } from '@nestjs/common';
|
||||
import { BaseWsExceptionFilter } from '@nestjs/websockets';
|
||||
|
||||
@@ -69,17 +68,6 @@ import {
|
||||
// 导入核心服务接口
|
||||
import { Position } from '../../core/location_broadcast_core/position.interface';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口,包含用户信息
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
sessionIds?: Set<string>;
|
||||
connectionTimeout?: NodeJS.Timeout;
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket异常过滤器
|
||||
*
|
||||
@@ -92,7 +80,7 @@ class WebSocketExceptionFilter extends BaseWsExceptionFilter {
|
||||
private readonly logger = new Logger(WebSocketExceptionFilter.name);
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
const client = host.switchToWs().getClient<ExtendedWebSocket>();
|
||||
const client = host.switchToWs().getClient<Socket>();
|
||||
|
||||
const error: ErrorResponse = {
|
||||
type: 'error',
|
||||
@@ -110,13 +98,7 @@ class WebSocketExceptionFilter extends BaseWsExceptionFilter {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'error', error);
|
||||
}
|
||||
|
||||
private sendMessage(client: ExtendedWebSocket, event: string, data: any) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ event, data }));
|
||||
}
|
||||
client.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +108,8 @@ class WebSocketExceptionFilter extends BaseWsExceptionFilter {
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
},
|
||||
path: '/location-broadcast', // WebSocket路径
|
||||
namespace: '/location-broadcast', // 使用专门的命名空间
|
||||
transports: ['websocket', 'polling'], // 支持WebSocket和轮询
|
||||
})
|
||||
@UseFilters(new WebSocketExceptionFilter())
|
||||
export class LocationBroadcastGateway
|
||||
@@ -136,15 +119,11 @@ export class LocationBroadcastGateway
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(LocationBroadcastGateway.name);
|
||||
private clients = new Map<string, ExtendedWebSocket>();
|
||||
private sessionRooms = new Map<string, Set<string>>(); // sessionId -> Set<clientId>
|
||||
|
||||
/** 连接超时时间(分钟) */
|
||||
private static readonly CONNECTION_TIMEOUT_MINUTES = 30;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_MINUTE = 60 * 1000;
|
||||
/** 心跳间隔(毫秒) */
|
||||
private static readonly HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
// 中间件实例
|
||||
private readonly rateLimitMiddleware = new RateLimitMiddleware();
|
||||
@@ -157,35 +136,51 @@ export class LocationBroadcastGateway
|
||||
|
||||
/**
|
||||
* WebSocket服务器初始化
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 配置Socket.IO服务器选项
|
||||
* 2. 设置中间件和事件监听器
|
||||
* 3. 初始化连接池和监控
|
||||
* 4. 记录服务器启动日志
|
||||
*/
|
||||
afterInit(server: Server) {
|
||||
this.logger.log('位置广播WebSocket服务器初始化完成', {
|
||||
path: '/location-broadcast',
|
||||
namespace: '/location-broadcast',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 设置心跳检测
|
||||
this.setupHeartbeat();
|
||||
// 设置服务器级别的中间件
|
||||
server.use((socket, next) => {
|
||||
this.logger.debug('新的WebSocket连接尝试', {
|
||||
socketId: socket.id,
|
||||
remoteAddress: socket.handshake.address,
|
||||
userAgent: socket.handshake.headers['user-agent'],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端连接
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 记录连接建立日志
|
||||
* 2. 初始化客户端状态
|
||||
* 3. 发送连接确认消息
|
||||
* 4. 设置连接超时和心跳检测
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
*/
|
||||
handleConnection(client: ExtendedWebSocket) {
|
||||
// 生成唯一ID
|
||||
client.id = this.generateClientId();
|
||||
client.sessionIds = new Set();
|
||||
client.isAlive = true;
|
||||
|
||||
this.clients.set(client.id, client);
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log('WebSocket客户端连接', {
|
||||
socketId: client.id,
|
||||
remoteAddress: client.handshake.address,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 记录连接事件到性能监控
|
||||
this.performanceMonitor.recordConnection(client as any, true);
|
||||
this.performanceMonitor.recordConnection(client, true);
|
||||
|
||||
// 发送连接确认消息
|
||||
const welcomeMessage = {
|
||||
@@ -195,34 +190,33 @@ export class LocationBroadcastGateway
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'welcome', welcomeMessage);
|
||||
client.emit('welcome', welcomeMessage);
|
||||
|
||||
// 设置连接超时
|
||||
this.setConnectionTimeout(client);
|
||||
// 设置连接超时(30分钟无活动自动断开)
|
||||
const timeout = setTimeout(() => {
|
||||
this.logger.warn('客户端连接超时,自动断开', {
|
||||
socketId: client.id,
|
||||
timeout: `${LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES}分钟`,
|
||||
});
|
||||
client.disconnect(true);
|
||||
}, LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES * LocationBroadcastGateway.MILLISECONDS_PER_MINUTE);
|
||||
|
||||
// 设置消息处理
|
||||
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;
|
||||
});
|
||||
// 将超时ID存储到客户端对象中
|
||||
(client as any).connectionTimeout = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端断开连接
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 清理客户端相关数据
|
||||
* 2. 从所有会话中移除用户
|
||||
* 3. 通知其他用户该用户离开
|
||||
* 4. 记录断开连接日志
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
*/
|
||||
async handleDisconnect(client: ExtendedWebSocket) {
|
||||
async handleDisconnect(client: Socket) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('WebSocket客户端断开连接', {
|
||||
@@ -231,39 +225,25 @@ export class LocationBroadcastGateway
|
||||
});
|
||||
|
||||
// 记录断开连接事件到性能监控
|
||||
this.performanceMonitor.recordConnection(client as any, false);
|
||||
this.performanceMonitor.recordConnection(client, false);
|
||||
|
||||
try {
|
||||
// 清理连接超时
|
||||
if (client.connectionTimeout) {
|
||||
clearTimeout(client.connectionTimeout);
|
||||
const timeout = (client as any).connectionTimeout;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
// 如果是已认证的客户端,进行清理
|
||||
if (client.userId) {
|
||||
await this.handleUserDisconnection(client, 'connection_lost');
|
||||
}
|
||||
|
||||
// 从客户端列表中移除
|
||||
this.clients.delete(client.id);
|
||||
|
||||
// 从所有会话房间中移除
|
||||
if (client.sessionIds) {
|
||||
for (const sessionId of client.sessionIds) {
|
||||
const room = this.sessionRooms.get(sessionId);
|
||||
if (room) {
|
||||
room.delete(client.id);
|
||||
if (room.size === 0) {
|
||||
this.sessionRooms.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
if (authenticatedClient.userId) {
|
||||
await this.handleUserDisconnection(authenticatedClient, 'connection_lost');
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log('客户端断开连接处理完成', {
|
||||
socketId: client.id,
|
||||
userId: client.userId || 'unknown',
|
||||
userId: authenticatedClient.userId || 'unknown',
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -277,37 +257,26 @@ export class LocationBroadcastGateway
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息路由
|
||||
*/
|
||||
private async handleMessage(client: ExtendedWebSocket, message: any) {
|
||||
const { event, data } = message;
|
||||
|
||||
switch (event) {
|
||||
case 'join_session':
|
||||
await this.handleJoinSession(client, data);
|
||||
break;
|
||||
case 'leave_session':
|
||||
await this.handleLeaveSession(client, data);
|
||||
break;
|
||||
case 'position_update':
|
||||
await this.handlePositionUpdate(client, data);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
await this.handleHeartbeat(client, data);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn('未知消息类型', {
|
||||
socketId: client.id,
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理加入会话消息
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 验证JWT令牌和用户身份
|
||||
* 2. 将用户添加到指定会话
|
||||
* 3. 获取会话中其他用户的位置信息
|
||||
* 4. 向用户发送会话加入成功响应
|
||||
* 5. 向会话中其他用户广播新用户加入通知
|
||||
*
|
||||
* @param client 已认证的WebSocket客户端
|
||||
* @param message 加入会话消息
|
||||
*/
|
||||
async handleJoinSession(client: ExtendedWebSocket, message: JoinSessionMessage) {
|
||||
@SubscribeMessage('join_session')
|
||||
@UseGuards(WebSocketAuthGuard)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async handleJoinSession(
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
@MessageBody() message: JoinSessionMessage,
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('处理加入会话请求', {
|
||||
@@ -319,16 +288,6 @@ export class LocationBroadcastGateway
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证认证状态
|
||||
if (!client.userId) {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '用户未认证',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 将用户添加到会话
|
||||
await this.locationBroadcastCore.addUserToSession(
|
||||
message.sessionId,
|
||||
@@ -384,7 +343,7 @@ export class LocationBroadcastGateway
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'session_joined', joinResponse);
|
||||
client.emit('session_joined', joinResponse);
|
||||
|
||||
// 5. 向会话中其他用户广播新用户加入通知
|
||||
const userJoinedNotification: UserJoinedNotification = {
|
||||
@@ -406,10 +365,10 @@ export class LocationBroadcastGateway
|
||||
};
|
||||
|
||||
// 广播给会话中的其他用户(排除当前用户)
|
||||
this.broadcastToSession(message.sessionId, 'user_joined', userJoinedNotification, client.id);
|
||||
client.to(message.sessionId).emit('user_joined', userJoinedNotification);
|
||||
|
||||
// 将客户端加入会话房间
|
||||
this.joinRoom(client, message.sessionId);
|
||||
// 将客户端加入Socket.IO房间(用于广播)
|
||||
client.join(message.sessionId);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log('用户成功加入会话', {
|
||||
@@ -434,7 +393,7 @@ export class LocationBroadcastGateway
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'JOIN_SESSION_FAILED',
|
||||
message: '加入会话失败',
|
||||
@@ -444,16 +403,30 @@ export class LocationBroadcastGateway
|
||||
},
|
||||
originalMessage: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'error', errorResponse);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理离开会话消息
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 验证用户身份和会话权限
|
||||
* 2. 从会话中移除用户
|
||||
* 3. 清理用户相关数据
|
||||
* 4. 向会话中其他用户广播用户离开通知
|
||||
* 5. 发送离开成功确认
|
||||
*
|
||||
* @param client 已认证的WebSocket客户端
|
||||
* @param message 离开会话消息
|
||||
*/
|
||||
async handleLeaveSession(client: ExtendedWebSocket, message: LeaveSessionMessage) {
|
||||
@SubscribeMessage('leave_session')
|
||||
@UseGuards(WebSocketAuthGuard)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async handleLeaveSession(
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
@MessageBody() message: LeaveSessionMessage,
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('处理离开会话请求', {
|
||||
@@ -466,16 +439,6 @@ export class LocationBroadcastGateway
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证认证状态
|
||||
if (!client.userId) {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '用户未认证',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 从会话中移除用户
|
||||
await this.locationBroadcastCore.removeUserFromSession(
|
||||
message.sessionId,
|
||||
@@ -491,10 +454,10 @@ export class LocationBroadcastGateway
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.broadcastToSession(message.sessionId, 'user_left', userLeftNotification, client.id);
|
||||
client.to(message.sessionId).emit('user_left', userLeftNotification);
|
||||
|
||||
// 3. 从会话房间中移除客户端
|
||||
this.leaveRoom(client, message.sessionId);
|
||||
// 3. 从Socket.IO房间中移除客户端
|
||||
client.leave(message.sessionId);
|
||||
|
||||
// 4. 发送离开成功确认
|
||||
const successResponse: SuccessResponse = {
|
||||
@@ -508,7 +471,7 @@ export class LocationBroadcastGateway
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'leave_session_success', successResponse);
|
||||
client.emit('leave_session_success', successResponse);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log('用户成功离开会话', {
|
||||
@@ -533,7 +496,7 @@ export class LocationBroadcastGateway
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'LEAVE_SESSION_FAILED',
|
||||
message: '离开会话失败',
|
||||
@@ -543,23 +506,37 @@ export class LocationBroadcastGateway
|
||||
},
|
||||
originalMessage: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'error', errorResponse);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理位置更新消息
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 验证位置数据的有效性
|
||||
* 2. 更新用户在Redis中的位置缓存
|
||||
* 3. 获取用户当前所在的会话
|
||||
* 4. 向会话中其他用户广播位置更新
|
||||
* 5. 可选:触发位置数据持久化
|
||||
*
|
||||
* @param client 已认证的WebSocket客户端
|
||||
* @param message 位置更新消息
|
||||
*/
|
||||
async handlePositionUpdate(client: ExtendedWebSocket, message: PositionUpdateMessage) {
|
||||
@SubscribeMessage('position_update')
|
||||
@UseGuards(WebSocketAuthGuard)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async handlePositionUpdate(
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
@MessageBody() message: PositionUpdateMessage,
|
||||
) {
|
||||
// 开始性能监控
|
||||
const perfContext = this.performanceMonitor.startMonitoring('position_update', client as any);
|
||||
const perfContext = this.performanceMonitor.startMonitoring('position_update', client);
|
||||
|
||||
// 检查频率限制
|
||||
const rateLimitAllowed = this.rateLimitMiddleware.checkRateLimit(client.userId || '', client.id);
|
||||
const rateLimitAllowed = this.rateLimitMiddleware.checkRateLimit(client.userId, client.id);
|
||||
if (!rateLimitAllowed) {
|
||||
this.rateLimitMiddleware.handleRateLimit(client as any, client.userId || '');
|
||||
this.rateLimitMiddleware.handleRateLimit(client, client.userId);
|
||||
this.performanceMonitor.endMonitoring(perfContext, false, 'Rate limit exceeded');
|
||||
return;
|
||||
}
|
||||
@@ -577,16 +554,6 @@ export class LocationBroadcastGateway
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证认证状态
|
||||
if (!client.userId) {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '用户未认证',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 构建位置对象
|
||||
const position: Position = {
|
||||
userId: client.userId,
|
||||
@@ -600,28 +567,32 @@ export class LocationBroadcastGateway
|
||||
// 2. 更新用户位置
|
||||
await this.locationBroadcastCore.setUserPosition(client.userId, position);
|
||||
|
||||
// 3. 向用户所在的所有会话广播位置更新
|
||||
if (client.sessionIds) {
|
||||
for (const sessionId of client.sessionIds) {
|
||||
const positionBroadcast: PositionBroadcast = {
|
||||
type: 'position_broadcast',
|
||||
userId: client.userId,
|
||||
position: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
mapId: position.mapId,
|
||||
timestamp: position.timestamp,
|
||||
metadata: position.metadata,
|
||||
},
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// 3. 获取用户当前会话(从Redis中获取)
|
||||
// 注意:这里需要从Redis获取用户的会话信息
|
||||
// 暂时使用客户端房间信息作为会话ID
|
||||
const rooms = Array.from(client.rooms);
|
||||
const sessionId = rooms.find(room => room !== client.id); // 排除socket自身的房间
|
||||
|
||||
this.broadcastToSession(sessionId, 'position_update', positionBroadcast, client.id);
|
||||
}
|
||||
if (sessionId) {
|
||||
// 4. 向会话中其他用户广播位置更新
|
||||
const positionBroadcast: PositionBroadcast = {
|
||||
type: 'position_broadcast',
|
||||
userId: client.userId,
|
||||
position: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
mapId: position.mapId,
|
||||
timestamp: position.timestamp,
|
||||
metadata: position.metadata,
|
||||
},
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
client.to(sessionId).emit('position_update', positionBroadcast);
|
||||
}
|
||||
|
||||
// 4. 发送位置更新成功确认
|
||||
// 5. 发送位置更新成功确认(可选)
|
||||
const successResponse: SuccessResponse = {
|
||||
type: 'success',
|
||||
message: '位置更新成功',
|
||||
@@ -635,7 +606,7 @@ export class LocationBroadcastGateway
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'position_update_success', successResponse);
|
||||
client.emit('position_update_success', successResponse);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.debug('位置更新处理完成', {
|
||||
@@ -643,6 +614,7 @@ export class LocationBroadcastGateway
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
mapId: message.mapId,
|
||||
sessionId,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -665,7 +637,7 @@ export class LocationBroadcastGateway
|
||||
// 结束性能监控(失败)
|
||||
this.performanceMonitor.endMonitoring(perfContext, false, error instanceof Error ? error.message : String(error));
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'POSITION_UPDATE_FAILED',
|
||||
message: '位置更新失败',
|
||||
@@ -675,16 +647,28 @@ export class LocationBroadcastGateway
|
||||
},
|
||||
originalMessage: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'error', errorResponse);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理心跳消息
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 接收客户端心跳请求
|
||||
* 2. 更新连接活跃时间
|
||||
* 3. 返回服务端时间戳
|
||||
* 4. 重置连接超时计时器
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param message 心跳消息
|
||||
*/
|
||||
async handleHeartbeat(client: ExtendedWebSocket, message: HeartbeatMessage) {
|
||||
@SubscribeMessage('heartbeat')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async handleHeartbeat(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() message: HeartbeatMessage,
|
||||
) {
|
||||
this.logger.debug('处理心跳请求', {
|
||||
operation: 'heartbeat',
|
||||
socketId: client.id,
|
||||
@@ -694,7 +678,21 @@ export class LocationBroadcastGateway
|
||||
|
||||
try {
|
||||
// 1. 重置连接超时
|
||||
this.setConnectionTimeout(client);
|
||||
const timeout = (client as any).connectionTimeout;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// 重新设置超时
|
||||
const newTimeout = setTimeout(() => {
|
||||
this.logger.warn('客户端连接超时,自动断开', {
|
||||
socketId: client.id,
|
||||
timeout: `${LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES}分钟`,
|
||||
});
|
||||
client.disconnect(true);
|
||||
}, LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES * LocationBroadcastGateway.MILLISECONDS_PER_MINUTE);
|
||||
|
||||
(client as any).connectionTimeout = newTimeout;
|
||||
}
|
||||
|
||||
// 2. 构建心跳响应
|
||||
const heartbeatResponse: HeartbeatResponse = {
|
||||
@@ -705,7 +703,7 @@ export class LocationBroadcastGateway
|
||||
};
|
||||
|
||||
// 3. 发送心跳响应
|
||||
this.sendMessage(client, 'heartbeat_response', heartbeatResponse);
|
||||
client.emit('heartbeat_response', heartbeatResponse);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('心跳处理失败', {
|
||||
@@ -713,16 +711,31 @@ export class LocationBroadcastGateway
|
||||
socketId: client.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// 心跳失败不抛出异常,避免断开连接
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户断开连接的清理工作
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 清理用户在所有会话中的数据
|
||||
* 2. 通知相关会话中的其他用户
|
||||
* 3. 清理Redis中的用户数据
|
||||
* 4. 记录断开连接的统计信息
|
||||
*
|
||||
* @param client 已认证的WebSocket客户端
|
||||
* @param reason 断开原因
|
||||
*/
|
||||
private async handleUserDisconnection(client: ExtendedWebSocket, reason: string): Promise<void> {
|
||||
private async handleUserDisconnection(
|
||||
client: AuthenticatedSocket,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. 获取用户所在的所有会话
|
||||
const sessionIds = Array.from(client.sessionIds || []);
|
||||
// 1. 获取用户所在的所有房间(会话)
|
||||
const rooms = Array.from(client.rooms);
|
||||
const sessionIds = rooms.filter(room => room !== client.id);
|
||||
|
||||
// 2. 从所有会话中移除用户并通知其他用户
|
||||
for (const sessionId of sessionIds) {
|
||||
@@ -730,19 +743,19 @@ export class LocationBroadcastGateway
|
||||
// 从会话中移除用户
|
||||
await this.locationBroadcastCore.removeUserFromSession(
|
||||
sessionId,
|
||||
client.userId!,
|
||||
client.userId,
|
||||
);
|
||||
|
||||
// 通知会话中的其他用户
|
||||
const userLeftNotification: UserLeftNotification = {
|
||||
type: 'user_left',
|
||||
userId: client.userId!,
|
||||
userId: client.userId,
|
||||
reason,
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.broadcastToSession(sessionId, 'user_left', userLeftNotification, client.id);
|
||||
client.to(sessionId).emit('user_left', userLeftNotification);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('从会话中移除用户失败', {
|
||||
@@ -755,7 +768,7 @@ export class LocationBroadcastGateway
|
||||
}
|
||||
|
||||
// 3. 清理用户的所有数据
|
||||
await this.locationBroadcastCore.cleanupUserData(client.userId!);
|
||||
await this.locationBroadcastCore.cleanupUserData(client.userId);
|
||||
|
||||
this.logger.log('用户断开连接清理完成', {
|
||||
socketId: client.id,
|
||||
@@ -774,103 +787,4 @@ export class LocationBroadcastGateway
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给客户端
|
||||
*/
|
||||
private sendMessage(client: ExtendedWebSocket, event: string, data: any) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ event, data }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向会话房间广播消息
|
||||
*/
|
||||
private broadcastToSession(sessionId: string, event: string, data: any, excludeClientId?: string) {
|
||||
const room = this.sessionRooms.get(sessionId);
|
||||
if (!room) return;
|
||||
|
||||
for (const clientId of room) {
|
||||
if (excludeClientId && clientId === excludeClientId) continue;
|
||||
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
this.sendMessage(client, event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将客户端加入会话房间
|
||||
*/
|
||||
private joinRoom(client: ExtendedWebSocket, sessionId: string) {
|
||||
if (!this.sessionRooms.has(sessionId)) {
|
||||
this.sessionRooms.set(sessionId, new Set());
|
||||
}
|
||||
|
||||
this.sessionRooms.get(sessionId)!.add(client.id);
|
||||
client.sessionIds!.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将客户端从会话房间移除
|
||||
*/
|
||||
private leaveRoom(client: ExtendedWebSocket, sessionId: string) {
|
||||
const room = this.sessionRooms.get(sessionId);
|
||||
if (room) {
|
||||
room.delete(client.id);
|
||||
if (room.size === 0) {
|
||||
this.sessionRooms.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
client.sessionIds!.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端ID
|
||||
*/
|
||||
private generateClientId(): string {
|
||||
return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接超时
|
||||
*/
|
||||
private setConnectionTimeout(client: ExtendedWebSocket) {
|
||||
if (client.connectionTimeout) {
|
||||
clearTimeout(client.connectionTimeout);
|
||||
}
|
||||
|
||||
client.connectionTimeout = setTimeout(() => {
|
||||
this.logger.warn('客户端连接超时,自动断开', {
|
||||
socketId: client.id,
|
||||
timeout: `${LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES}分钟`,
|
||||
});
|
||||
client.close();
|
||||
}, LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES * LocationBroadcastGateway.MILLISECONDS_PER_MINUTE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置心跳检测
|
||||
*/
|
||||
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();
|
||||
}
|
||||
});
|
||||
}, LocationBroadcastGateway.HEARTBEAT_INTERVAL);
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,7 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
}
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
/**
|
||||
* 性能指标接口
|
||||
@@ -210,7 +203,7 @@ export class PerformanceMonitorMiddleware {
|
||||
* @param client WebSocket客户端
|
||||
* @returns 监控上下文
|
||||
*/
|
||||
startMonitoring(eventName: string, client: ExtendedWebSocket): { startTime: [number, number]; eventName: string; client: ExtendedWebSocket } {
|
||||
startMonitoring(eventName: string, client: Socket): { startTime: [number, number]; eventName: string; client: Socket } {
|
||||
const startTime = process.hrtime();
|
||||
|
||||
// 记录连接
|
||||
@@ -227,7 +220,7 @@ export class PerformanceMonitorMiddleware {
|
||||
* @param error 错误信息
|
||||
*/
|
||||
endMonitoring(
|
||||
context: { startTime: [number, number]; eventName: string; client: ExtendedWebSocket },
|
||||
context: { startTime: [number, number]; eventName: string; client: Socket },
|
||||
success: boolean = true,
|
||||
error?: string,
|
||||
): void {
|
||||
@@ -238,7 +231,7 @@ export class PerformanceMonitorMiddleware {
|
||||
eventName: context.eventName,
|
||||
duration,
|
||||
timestamp: Date.now(),
|
||||
userId: context.client.userId,
|
||||
userId: (context.client as any).userId,
|
||||
socketId: context.client.id,
|
||||
success,
|
||||
error,
|
||||
@@ -253,7 +246,7 @@ export class PerformanceMonitorMiddleware {
|
||||
* @param client WebSocket客户端
|
||||
* @param connected 是否连接
|
||||
*/
|
||||
recordConnection(client: ExtendedWebSocket, connected: boolean): void {
|
||||
recordConnection(client: Socket, connected: boolean): void {
|
||||
if (connected) {
|
||||
this.connectionCount++;
|
||||
this.activeConnections.add(client.id);
|
||||
@@ -647,7 +640,7 @@ export function PerformanceMonitor(eventName?: string) {
|
||||
const finalEventName = eventName || propertyName;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const client = args[0] as ExtendedWebSocket;
|
||||
const client = args[0] as Socket;
|
||||
const performanceMonitor = new PerformanceMonitorMiddleware();
|
||||
|
||||
const context = performanceMonitor.startMonitoring(finalEventName, client);
|
||||
|
||||
@@ -29,14 +29,7 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
}
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
/**
|
||||
* 限流配置接口
|
||||
@@ -193,7 +186,7 @@ export class RateLimitMiddleware {
|
||||
* @param client WebSocket客户端
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
handleRateLimit(client: ExtendedWebSocket, userId: string): void {
|
||||
handleRateLimit(client: Socket, userId: string): void {
|
||||
const error = {
|
||||
type: 'error',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
@@ -206,9 +199,7 @@ export class RateLimitMiddleware {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ event: 'error', data: error }));
|
||||
}
|
||||
client.emit('error', error);
|
||||
|
||||
this.logger.debug('发送限流错误响应', {
|
||||
userId,
|
||||
@@ -339,7 +330,7 @@ export function PositionUpdateRateLimit() {
|
||||
const method = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const client = args[0] as ExtendedWebSocket;
|
||||
const client = args[0] as Socket & { userId?: string };
|
||||
const rateLimitMiddleware = new RateLimitMiddleware();
|
||||
|
||||
if (client.userId) {
|
||||
|
||||
@@ -20,41 +20,34 @@
|
||||
* - 提供错误处理和日志记录
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 重构为原生WebSocket - 适配原生WebSocket接口 (修改者: moyin)
|
||||
* - 2026-01-08: 代码重构 - 拆分长方法,提高代码可读性和可维护性 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, CanActivate, ExecutionContext, Logger } from '@nestjs/common';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
import { Socket } from 'socket.io';
|
||||
import { LoginCoreService, JwtPayload } from '../../core/login_core/login_core.service';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket客户端接口,包含用户信息
|
||||
*
|
||||
* 职责:
|
||||
* - 扩展原生WebSocket接口
|
||||
* - 扩展Socket.io的Socket接口
|
||||
* - 添加用户认证信息到客户端对象
|
||||
* - 提供类型安全的用户数据访问
|
||||
*/
|
||||
export interface AuthenticatedSocket extends WebSocket {
|
||||
/** 客户端ID */
|
||||
id: string;
|
||||
export interface AuthenticatedSocket extends Socket {
|
||||
/** 认证用户信息 */
|
||||
user?: JwtPayload;
|
||||
user: JwtPayload;
|
||||
/** 用户ID(便于快速访问) */
|
||||
userId?: string;
|
||||
userId: string;
|
||||
/** 认证时间戳 */
|
||||
authenticatedAt?: number;
|
||||
/** 会话ID集合 */
|
||||
sessionIds?: Set<string>;
|
||||
/** 连接超时 */
|
||||
connectionTimeout?: NodeJS.Timeout;
|
||||
/** 心跳状态 */
|
||||
isAlive?: boolean;
|
||||
authenticatedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -78,9 +71,19 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
* @param context 执行上下文,包含WebSocket客户端信息
|
||||
* @returns Promise<boolean> 认证是否成功
|
||||
* @throws WsException 当令牌缺失或无效时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @SubscribeMessage('join_session')
|
||||
* @UseGuards(WebSocketAuthGuard)
|
||||
* handleJoinSession(@ConnectedSocket() client: AuthenticatedSocket) {
|
||||
* // 此方法需要有效的JWT令牌才能访问
|
||||
* console.log('认证用户:', client.user.username);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const client = context.switchToWs().getClient<AuthenticatedSocket>();
|
||||
const client = context.switchToWs().getClient<Socket>();
|
||||
const data = context.switchToWs().getData();
|
||||
|
||||
this.logAuthStart(client, context);
|
||||
@@ -92,15 +95,6 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
this.handleMissingToken(client);
|
||||
}
|
||||
|
||||
// 如果是缓存的认证信息,直接返回成功
|
||||
if (token === 'cached' && client.user && client.userId) {
|
||||
this.logger.debug('使用缓存的认证信息', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
||||
this.attachUserToClient(client, payload);
|
||||
this.logAuthSuccess(client, payload);
|
||||
@@ -119,7 +113,7 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
* @param context 执行上下文
|
||||
* @private
|
||||
*/
|
||||
private logAuthStart(client: AuthenticatedSocket, context: ExecutionContext): void {
|
||||
private logAuthStart(client: Socket, context: ExecutionContext): void {
|
||||
this.logger.log('开始WebSocket认证验证', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
@@ -135,7 +129,7 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
* @throws WsException
|
||||
* @private
|
||||
*/
|
||||
private handleMissingToken(client: AuthenticatedSocket): never {
|
||||
private handleMissingToken(client: Socket): never {
|
||||
this.logger.warn('WebSocket认证失败:缺少认证令牌', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
@@ -157,10 +151,11 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
* @param payload JWT载荷
|
||||
* @private
|
||||
*/
|
||||
private attachUserToClient(client: AuthenticatedSocket, payload: JwtPayload): void {
|
||||
client.user = payload;
|
||||
client.userId = payload.sub;
|
||||
client.authenticatedAt = Date.now();
|
||||
private attachUserToClient(client: Socket, payload: JwtPayload): void {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
authenticatedClient.user = payload;
|
||||
authenticatedClient.userId = payload.sub;
|
||||
authenticatedClient.authenticatedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +165,7 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
* @param payload JWT载荷
|
||||
* @private
|
||||
*/
|
||||
private logAuthSuccess(client: AuthenticatedSocket, payload: JwtPayload): void {
|
||||
private logAuthSuccess(client: Socket, payload: JwtPayload): void {
|
||||
this.logger.log('WebSocket认证成功', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
@@ -189,7 +184,7 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
* @throws WsException
|
||||
* @private
|
||||
*/
|
||||
private handleAuthError(client: AuthenticatedSocket, error: any): never {
|
||||
private handleAuthError(client: Socket, error: any): never {
|
||||
this.logger.error('WebSocket认证失败', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
@@ -219,18 +214,43 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 优先从消息数据中提取token字段
|
||||
* 2. 检查是否已经认证过(用于后续消息)
|
||||
* 3. 从URL查询参数中提取token(如果可用)
|
||||
* 2. 从连接握手的查询参数中提取token
|
||||
* 3. 从连接握手的认证头中提取Bearer令牌
|
||||
* 4. 从Socket客户端的自定义属性中提取
|
||||
*
|
||||
* 支持的令牌传递方式:
|
||||
* - 消息数据: { token: "jwt_token" }
|
||||
* - 缓存认证: 使用已验证的用户信息
|
||||
* - 查询参数: ?token=jwt_token
|
||||
* - 认证头: Authorization: Bearer jwt_token
|
||||
* - Socket属性: client.handshake.auth.token
|
||||
*
|
||||
* @param client WebSocket客户端对象
|
||||
* @param data 消息数据
|
||||
* @returns JWT令牌字符串或undefined
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 方式1: 在消息中传递token
|
||||
* socket.emit('join_session', {
|
||||
* type: 'join_session',
|
||||
* sessionId: 'session123',
|
||||
* token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
* });
|
||||
*
|
||||
* // 方式2: 在连接时传递token
|
||||
* const socket = io('ws://localhost:3000', {
|
||||
* query: { token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }
|
||||
* });
|
||||
*
|
||||
* // 方式3: 在认证头中传递token
|
||||
* const socket = io('ws://localhost:3000', {
|
||||
* extraHeaders: {
|
||||
* 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
private extractToken(client: AuthenticatedSocket, data: any): string | undefined {
|
||||
private extractToken(client: Socket, data: any): string | undefined {
|
||||
// 1. 优先从消息数据中提取token
|
||||
if (data && typeof data === 'object' && data.token) {
|
||||
this.logger.debug('从消息数据中提取到token', {
|
||||
@@ -240,11 +260,45 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
return data.token;
|
||||
}
|
||||
|
||||
// 2. 检查是否已经认证过(用于后续消息)
|
||||
if (client.user && client.userId) {
|
||||
// 2. 从查询参数中提取token
|
||||
const queryToken = client.handshake.query?.token;
|
||||
if (queryToken && typeof queryToken === 'string') {
|
||||
this.logger.debug('从查询参数中提取到token', {
|
||||
socketId: client.id,
|
||||
source: 'query_params'
|
||||
});
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
// 3. 从认证头中提取Bearer令牌
|
||||
const authHeader = client.handshake.headers?.authorization;
|
||||
if (authHeader && typeof authHeader === 'string') {
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type === 'Bearer' && token) {
|
||||
this.logger.debug('从认证头中提取到token', {
|
||||
socketId: client.id,
|
||||
source: 'auth_header'
|
||||
});
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 从Socket认证对象中提取token
|
||||
const authToken = client.handshake.auth?.token;
|
||||
if (authToken && typeof authToken === 'string') {
|
||||
this.logger.debug('从Socket认证对象中提取到token', {
|
||||
socketId: client.id,
|
||||
source: 'socket_auth'
|
||||
});
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// 5. 检查是否已经认证过(用于后续消息)
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
if (authenticatedClient.user && authenticatedClient.userId) {
|
||||
this.logger.debug('使用已认证的用户信息', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
userId: authenticatedClient.userId,
|
||||
source: 'cached_auth'
|
||||
});
|
||||
return 'cached'; // 返回特殊标识,表示使用缓存的认证信息
|
||||
@@ -254,7 +308,9 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
socketId: client.id,
|
||||
availableSources: {
|
||||
messageData: !!data?.token,
|
||||
cachedAuth: !!(client.user && client.userId)
|
||||
queryParams: !!client.handshake.query?.token,
|
||||
authHeader: !!client.handshake.headers?.authorization,
|
||||
socketAuth: !!client.handshake.auth?.token
|
||||
}
|
||||
});
|
||||
|
||||
@@ -266,9 +322,10 @@ export class WebSocketAuthGuard implements CanActivate {
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
*/
|
||||
static clearAuthentication(client: AuthenticatedSocket): void {
|
||||
delete client.user;
|
||||
delete client.userId;
|
||||
delete client.authenticatedAt;
|
||||
static clearAuthentication(client: Socket): void {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
delete authenticatedClient.user;
|
||||
delete authenticatedClient.userId;
|
||||
delete authenticatedClient.authenticatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
# 通知系统 (Notice System)
|
||||
|
||||
## 功能概述
|
||||
|
||||
这是一个完整的通知系统,支持实时通知推送、定时通知、通知状态管理等功能。
|
||||
|
||||
## 主要特性
|
||||
|
||||
- ✅ 实时WebSocket通知推送
|
||||
- ✅ 定时通知发送
|
||||
- ✅ 通知状态管理(待发送、已发送、已读、失败)
|
||||
- ✅ 支持单用户通知和广播通知
|
||||
- ✅ 通知类型分类(系统、用户、广播)
|
||||
- ✅ 未读通知计数
|
||||
- ✅ RESTful API接口
|
||||
|
||||
## API接口
|
||||
|
||||
### 1. 创建通知
|
||||
```
|
||||
POST /api/notices
|
||||
```
|
||||
|
||||
### 2. 获取通知列表
|
||||
```
|
||||
GET /api/notices
|
||||
GET /api/notices?all=true # 管理员获取所有通知
|
||||
```
|
||||
|
||||
### 3. 获取未读通知数量
|
||||
```
|
||||
GET /api/notices/unread-count
|
||||
```
|
||||
|
||||
### 4. 获取通知详情
|
||||
```
|
||||
GET /api/notices/:id
|
||||
```
|
||||
|
||||
### 5. 标记通知为已读
|
||||
```
|
||||
PATCH /api/notices/:id/read
|
||||
```
|
||||
|
||||
### 6. 发送系统通知
|
||||
```
|
||||
POST /api/notices/system
|
||||
```
|
||||
|
||||
### 7. 发送广播通知
|
||||
```
|
||||
POST /api/notices/broadcast
|
||||
```
|
||||
|
||||
## WebSocket连接
|
||||
|
||||
### 连接地址
|
||||
```
|
||||
ws://localhost:3000/ws/notice
|
||||
```
|
||||
|
||||
### 认证
|
||||
连接后需要发送认证消息:
|
||||
```json
|
||||
{
|
||||
"event": "authenticate",
|
||||
"data": { "userId": 123 }
|
||||
}
|
||||
```
|
||||
|
||||
### 接收通知
|
||||
客户端会收到以下格式的通知:
|
||||
```json
|
||||
{
|
||||
"type": "notice",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"title": "通知标题",
|
||||
"content": "通知内容",
|
||||
"type": "system",
|
||||
"status": "sent",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 前端JavaScript示例
|
||||
```javascript
|
||||
// 建立WebSocket连接
|
||||
const ws = new WebSocket('ws://localhost:3000/ws/notice');
|
||||
|
||||
// 连接成功后认证
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({
|
||||
event: 'authenticate',
|
||||
data: { userId: 123 }
|
||||
}));
|
||||
};
|
||||
|
||||
// 接收通知
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notice') {
|
||||
console.log('收到新通知:', message.data);
|
||||
// 在UI中显示通知
|
||||
showNotification(message.data);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取通知列表
|
||||
async function getNotices() {
|
||||
const response = await fetch('/api/notices', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 标记通知为已读
|
||||
async function markAsRead(noticeId) {
|
||||
await fetch(`/api/notices/${noticeId}/read`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 后端使用示例
|
||||
```typescript
|
||||
// 注入NoticeService
|
||||
constructor(private readonly noticeService: NoticeService) {}
|
||||
|
||||
// 发送系统通知
|
||||
await this.noticeService.sendSystemNotice(
|
||||
'系统维护通知',
|
||||
'系统将于今晚22:00进行维护',
|
||||
userId
|
||||
);
|
||||
|
||||
// 发送广播通知
|
||||
await this.noticeService.sendBroadcast(
|
||||
'新功能上线',
|
||||
'我们上线了新的通知功能!'
|
||||
);
|
||||
|
||||
// 创建定时通知
|
||||
await this.noticeService.create({
|
||||
title: '会议提醒',
|
||||
content: '您有一个会议将在30分钟后开始',
|
||||
userId: 123,
|
||||
scheduledAt: new Date(Date.now() + 30 * 60 * 1000).toISOString()
|
||||
});
|
||||
```
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
通知表包含以下字段:
|
||||
- `id`: 主键
|
||||
- `title`: 通知标题
|
||||
- `content`: 通知内容
|
||||
- `type`: 通知类型(system/user/broadcast)
|
||||
- `status`: 通知状态(pending/sent/read/failed)
|
||||
- `userId`: 接收者ID(null表示广播)
|
||||
- `senderId`: 发送者ID
|
||||
- `scheduledAt`: 计划发送时间
|
||||
- `sentAt`: 实际发送时间
|
||||
- `readAt`: 阅读时间
|
||||
- `metadata`: 额外数据(JSON格式)
|
||||
- `createdAt`: 创建时间
|
||||
- `updatedAt`: 更新时间
|
||||
|
||||
## 定时任务
|
||||
|
||||
系统每分钟自动检查并发送到期的定时通知。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 需要在主模块中导入 `NoticeModule`
|
||||
2. 确保数据库中存在 `notices` 表
|
||||
3. WebSocket连接需要用户认证
|
||||
4. 定时通知依赖 `@nestjs/schedule` 包
|
||||
@@ -1,38 +0,0 @@
|
||||
import { IsString, IsOptional, IsNumber, IsEnum, IsDateString, IsObject } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NoticeType } from '../notice.entity';
|
||||
|
||||
export class CreateNoticeDto {
|
||||
@ApiProperty({ description: '通知标题' })
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '通知内容' })
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: NoticeType, description: '通知类型' })
|
||||
@IsOptional()
|
||||
@IsEnum(NoticeType)
|
||||
type?: NoticeType;
|
||||
|
||||
@ApiPropertyOptional({ description: '接收者用户ID,不填表示广播' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
userId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '发送者用户ID' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
senderId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '计划发送时间' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '额外元数据' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { NoticeType, NoticeStatus } from '../notice.entity';
|
||||
|
||||
export class NoticeResponseDto {
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({ enum: NoticeType })
|
||||
type: NoticeType;
|
||||
|
||||
@ApiProperty({ enum: NoticeStatus })
|
||||
status: NoticeStatus;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
userId: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
senderId: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
scheduledAt: Date | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sentAt: Date | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
readAt: Date | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
metadata: Record<string, any> | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from './notice.entity';
|
||||
export * from './notice.service';
|
||||
export * from './notice.controller';
|
||||
export * from './notice.gateway';
|
||||
export * from './notice.module';
|
||||
export * from './dto/create-notice.dto';
|
||||
export * from './dto/notice-response.dto';
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 创建通知表
|
||||
CREATE TABLE IF NOT EXISTS `notices` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL COMMENT '通知标题',
|
||||
`content` text NOT NULL COMMENT '通知内容',
|
||||
`type` enum('system','user','broadcast') NOT NULL DEFAULT 'system' COMMENT '通知类型',
|
||||
`status` enum('pending','sent','read','failed') NOT NULL DEFAULT 'pending' COMMENT '通知状态',
|
||||
`userId` int DEFAULT NULL COMMENT '接收者用户ID,NULL表示广播',
|
||||
`senderId` int DEFAULT NULL COMMENT '发送者用户ID',
|
||||
`scheduledAt` datetime DEFAULT NULL COMMENT '计划发送时间',
|
||||
`sentAt` datetime DEFAULT NULL COMMENT '实际发送时间',
|
||||
`readAt` datetime DEFAULT NULL COMMENT '阅读时间',
|
||||
`metadata` json DEFAULT NULL COMMENT '额外数据',
|
||||
`createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间',
|
||||
`updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_notices_user_id` (`userId`),
|
||||
KEY `idx_notices_status` (`status`),
|
||||
KEY `idx_notices_scheduled_at` (`scheduledAt`),
|
||||
KEY `idx_notices_created_at` (`createdAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表';
|
||||
@@ -1,87 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { NoticeService } from './notice.service';
|
||||
import { CreateNoticeDto } from './dto/create-notice.dto';
|
||||
import { NoticeResponseDto } from './dto/notice-response.dto';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
|
||||
@ApiTags('通知管理')
|
||||
@Controller('api/notices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class NoticeController {
|
||||
constructor(private readonly noticeService: NoticeService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建通知' })
|
||||
@ApiResponse({ status: 201, description: '通知创建成功', type: NoticeResponseDto })
|
||||
async create(@Body() createNoticeDto: CreateNoticeDto): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.create(createNoticeDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取通知列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: [NoticeResponseDto] })
|
||||
async findAll(
|
||||
@CurrentUser() user: any,
|
||||
@Query('all') all?: string,
|
||||
): Promise<NoticeResponseDto[]> {
|
||||
// 如果是管理员且指定了all参数,返回所有通知
|
||||
const userId = all === 'true' && user.isAdmin ? undefined : user.id;
|
||||
return this.noticeService.findAll(userId);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@ApiOperation({ summary: '获取未读通知数量' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getUnreadCount(@CurrentUser() user: any): Promise<{ count: number }> {
|
||||
const count = await this.noticeService.getUserUnreadCount(user.id);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取通知详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: NoticeResponseDto })
|
||||
async findOne(@Param('id', ParseIntPipe) id: number): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
@ApiOperation({ summary: '标记通知为已读' })
|
||||
@ApiResponse({ status: 200, description: '标记成功', type: NoticeResponseDto })
|
||||
async markAsRead(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.markAsRead(id, user.id);
|
||||
}
|
||||
|
||||
@Post('system')
|
||||
@ApiOperation({ summary: '发送系统通知' })
|
||||
@ApiResponse({ status: 201, description: '发送成功', type: NoticeResponseDto })
|
||||
async sendSystemNotice(
|
||||
@Body() body: { title: string; content: string; userId?: number },
|
||||
): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.sendSystemNotice(body.title, body.content, body.userId);
|
||||
}
|
||||
|
||||
@Post('broadcast')
|
||||
@ApiOperation({ summary: '发送广播通知' })
|
||||
@ApiResponse({ status: 201, description: '发送成功', type: NoticeResponseDto })
|
||||
async sendBroadcast(
|
||||
@Body() body: { title: string; content: string },
|
||||
): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.sendBroadcast(body.title, body.content);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export enum NoticeType {
|
||||
SYSTEM = 'system',
|
||||
USER = 'user',
|
||||
BROADCAST = 'broadcast',
|
||||
}
|
||||
|
||||
export enum NoticeStatus {
|
||||
PENDING = 'pending',
|
||||
SENT = 'sent',
|
||||
READ = 'read',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
@Entity('notices')
|
||||
export class Notice {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: NoticeType,
|
||||
default: NoticeType.SYSTEM,
|
||||
})
|
||||
type: NoticeType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: NoticeStatus,
|
||||
default: NoticeStatus.PENDING,
|
||||
})
|
||||
status: NoticeStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userId: number; // 接收者ID,null表示广播通知
|
||||
|
||||
@Column({ nullable: true })
|
||||
senderId: number; // 发送者ID
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
scheduledAt: Date; // 计划发送时间
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
sentAt: Date; // 实际发送时间
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
readAt: Date; // 阅读时间
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata: Record<string, any>; // 额外数据
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server } from 'ws';
|
||||
import * as WebSocket from 'ws';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
interface AuthenticatedSocket extends WebSocket {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*',
|
||||
},
|
||||
path: '/ws/notice',
|
||||
})
|
||||
export class NoticeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(NoticeGateway.name);
|
||||
private readonly userSockets = new Map<number, Set<AuthenticatedSocket>>();
|
||||
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
this.logger.log(`Client connected: ${client.readyState}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: AuthenticatedSocket) {
|
||||
this.logger.log(`Client disconnected`);
|
||||
|
||||
if (client.userId) {
|
||||
const userSockets = this.userSockets.get(client.userId);
|
||||
if (userSockets) {
|
||||
userSockets.delete(client);
|
||||
if (userSockets.size === 0) {
|
||||
this.userSockets.delete(client.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('authenticate')
|
||||
handleAuthenticate(
|
||||
@MessageBody() data: { userId: number },
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
const { userId } = data;
|
||||
|
||||
if (!userId) {
|
||||
client.send(JSON.stringify({ error: 'User ID is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
client.userId = userId;
|
||||
|
||||
if (!this.userSockets.has(userId)) {
|
||||
this.userSockets.set(userId, new Set());
|
||||
}
|
||||
this.userSockets.get(userId)!.add(client);
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'authenticated',
|
||||
data: { userId }
|
||||
}));
|
||||
|
||||
this.logger.log(`User ${userId} authenticated`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('ping')
|
||||
handlePing(@ConnectedSocket() client: AuthenticatedSocket) {
|
||||
client.send(JSON.stringify({ type: 'pong' }));
|
||||
}
|
||||
|
||||
// 发送消息给特定用户
|
||||
sendToUser(userId: number, message: any) {
|
||||
const userSockets = this.userSockets.get(userId);
|
||||
if (userSockets) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
userSockets.forEach(socket => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(messageStr);
|
||||
}
|
||||
});
|
||||
this.logger.log(`Message sent to user ${userId}`);
|
||||
} else {
|
||||
this.logger.warn(`User ${userId} not connected`);
|
||||
}
|
||||
}
|
||||
|
||||
// 广播消息给所有连接的用户
|
||||
broadcast(message: any) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
this.server.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageStr);
|
||||
}
|
||||
});
|
||||
this.logger.log('Message broadcasted to all clients');
|
||||
}
|
||||
|
||||
// 获取在线用户数量
|
||||
getOnlineUsersCount(): number {
|
||||
return this.userSockets.size;
|
||||
}
|
||||
|
||||
// 获取在线用户列表
|
||||
getOnlineUsers(): number[] {
|
||||
return Array.from(this.userSockets.keys());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { Notice } from './notice.entity';
|
||||
import { NoticeService } from './notice.service';
|
||||
import { NoticeController } from './notice.controller';
|
||||
import { NoticeGateway } from './notice.gateway';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Notice]),
|
||||
ScheduleModule.forRoot(),
|
||||
LoginCoreModule,
|
||||
],
|
||||
controllers: [NoticeController],
|
||||
providers: [NoticeService, NoticeGateway],
|
||||
exports: [NoticeService, NoticeGateway],
|
||||
})
|
||||
export class NoticeModule {}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NoticeService } from './notice.service';
|
||||
import { NoticeGateway } from './notice.gateway';
|
||||
import { Notice, NoticeStatus, NoticeType } from './notice.entity';
|
||||
|
||||
describe('NoticeService', () => {
|
||||
let service: NoticeService;
|
||||
let repository: Repository<Notice>;
|
||||
let gateway: NoticeGateway;
|
||||
|
||||
const mockRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGateway = {
|
||||
sendToUser: jest.fn(),
|
||||
broadcast: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
NoticeService,
|
||||
{
|
||||
provide: getRepositoryToken(Notice),
|
||||
useValue: mockRepository,
|
||||
},
|
||||
{
|
||||
provide: NoticeGateway,
|
||||
useValue: mockGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<NoticeService>(NoticeService);
|
||||
repository = module.get<Repository<Notice>>(getRepositoryToken(Notice));
|
||||
gateway = module.get<NoticeGateway>(NoticeGateway);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create and send notice immediately when no scheduledAt', async () => {
|
||||
const createDto = {
|
||||
title: 'Test Notice',
|
||||
content: 'Test Content',
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
const mockNotice = {
|
||||
id: 1,
|
||||
...createDto,
|
||||
status: NoticeStatus.PENDING,
|
||||
type: NoticeType.SYSTEM,
|
||||
scheduledAt: null,
|
||||
};
|
||||
|
||||
mockRepository.create.mockReturnValue(mockNotice);
|
||||
mockRepository.save.mockResolvedValueOnce(mockNotice);
|
||||
mockRepository.save.mockResolvedValueOnce({
|
||||
...mockNotice,
|
||||
status: NoticeStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.create(createDto);
|
||||
|
||||
expect(mockRepository.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
scheduledAt: null,
|
||||
});
|
||||
expect(mockRepository.save).toHaveBeenCalledTimes(2);
|
||||
expect(mockGateway.sendToUser).toHaveBeenCalledWith(1, {
|
||||
type: 'notice',
|
||||
data: mockNotice,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create scheduled notice without sending immediately', async () => {
|
||||
const scheduledAt = new Date(Date.now() + 3600000); // 1 hour later
|
||||
const createDto = {
|
||||
title: 'Scheduled Notice',
|
||||
content: 'Scheduled Content',
|
||||
scheduledAt: scheduledAt.toISOString(),
|
||||
};
|
||||
|
||||
const mockNotice = {
|
||||
id: 1,
|
||||
...createDto,
|
||||
scheduledAt,
|
||||
status: NoticeStatus.PENDING,
|
||||
};
|
||||
|
||||
mockRepository.create.mockReturnValue(mockNotice);
|
||||
mockRepository.save.mockResolvedValue(mockNotice);
|
||||
|
||||
const result = await service.create(createDto);
|
||||
|
||||
expect(mockGateway.sendToUser).not.toHaveBeenCalled();
|
||||
expect(mockGateway.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendSystemNotice', () => {
|
||||
it('should create and send system notice', async () => {
|
||||
const mockNotice = {
|
||||
id: 1,
|
||||
title: 'System Notice',
|
||||
content: 'System Content',
|
||||
type: NoticeType.SYSTEM,
|
||||
userId: 1,
|
||||
status: NoticeStatus.PENDING,
|
||||
};
|
||||
|
||||
mockRepository.create.mockReturnValue(mockNotice);
|
||||
mockRepository.save.mockResolvedValueOnce(mockNotice);
|
||||
mockRepository.save.mockResolvedValueOnce({
|
||||
...mockNotice,
|
||||
status: NoticeStatus.SENT,
|
||||
});
|
||||
|
||||
const result = await service.sendSystemNotice('System Notice', 'System Content', 1);
|
||||
|
||||
expect(result.type).toBe(NoticeType.SYSTEM);
|
||||
expect(mockGateway.sendToUser).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendBroadcast', () => {
|
||||
it('should create and send broadcast notice', async () => {
|
||||
const mockNotice = {
|
||||
id: 1,
|
||||
title: 'Broadcast Notice',
|
||||
content: 'Broadcast Content',
|
||||
type: NoticeType.BROADCAST,
|
||||
userId: null,
|
||||
status: NoticeStatus.PENDING,
|
||||
};
|
||||
|
||||
mockRepository.create.mockReturnValue(mockNotice);
|
||||
mockRepository.save.mockResolvedValueOnce(mockNotice);
|
||||
mockRepository.save.mockResolvedValueOnce({
|
||||
...mockNotice,
|
||||
status: NoticeStatus.SENT,
|
||||
});
|
||||
|
||||
const result = await service.sendBroadcast('Broadcast Notice', 'Broadcast Content');
|
||||
|
||||
expect(result.type).toBe(NoticeType.BROADCAST);
|
||||
expect(mockGateway.broadcast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsRead', () => {
|
||||
it('should mark notice as read', async () => {
|
||||
const mockNotice = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
status: NoticeStatus.SENT,
|
||||
};
|
||||
|
||||
const updatedNotice = {
|
||||
...mockNotice,
|
||||
status: NoticeStatus.READ,
|
||||
readAt: new Date(),
|
||||
};
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(mockNotice);
|
||||
mockRepository.save.mockResolvedValue(updatedNotice);
|
||||
|
||||
const result = await service.markAsRead(1, 1);
|
||||
|
||||
expect(result.status).toBe(NoticeStatus.READ);
|
||||
expect(result.readAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserUnreadCount', () => {
|
||||
it('should return unread count for user', async () => {
|
||||
mockRepository.count.mockResolvedValue(5);
|
||||
|
||||
const count = await service.getUserUnreadCount(1);
|
||||
|
||||
expect(count).toBe(5);
|
||||
expect(mockRepository.count).toHaveBeenCalledWith({
|
||||
where: [
|
||||
{ userId: 1, status: NoticeStatus.SENT },
|
||||
{ userId: null, status: NoticeStatus.SENT },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThanOrEqual } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Notice, NoticeStatus, NoticeType } from './notice.entity';
|
||||
import { CreateNoticeDto } from './dto/create-notice.dto';
|
||||
import { NoticeGateway } from './notice.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class NoticeService {
|
||||
private readonly logger = new Logger(NoticeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Notice)
|
||||
private readonly noticeRepository: Repository<Notice>,
|
||||
private readonly noticeGateway: NoticeGateway,
|
||||
) {}
|
||||
|
||||
async create(createNoticeDto: CreateNoticeDto): Promise<Notice> {
|
||||
const notice = this.noticeRepository.create({
|
||||
...createNoticeDto,
|
||||
scheduledAt: createNoticeDto.scheduledAt ? new Date(createNoticeDto.scheduledAt) : null,
|
||||
});
|
||||
|
||||
const savedNotice = await this.noticeRepository.save(notice);
|
||||
|
||||
// 如果没有设置计划时间,立即发送
|
||||
if (!savedNotice.scheduledAt) {
|
||||
await this.sendNotice(savedNotice);
|
||||
}
|
||||
|
||||
return savedNotice;
|
||||
}
|
||||
|
||||
async findAll(userId?: number): Promise<Notice[]> {
|
||||
const query = this.noticeRepository.createQueryBuilder('notice');
|
||||
|
||||
if (userId) {
|
||||
query.where('notice.userId = :userId OR notice.userId IS NULL', { userId });
|
||||
}
|
||||
|
||||
return query.orderBy('notice.createdAt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Notice> {
|
||||
const notice = await this.noticeRepository.findOne({ where: { id } });
|
||||
if (!notice) {
|
||||
throw new NotFoundException(`Notice with ID ${id} not found`);
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
async markAsRead(id: number, userId?: number): Promise<Notice> {
|
||||
const notice = await this.findById(id);
|
||||
|
||||
// 检查权限:只能标记自己的通知或广播通知为已读
|
||||
if (notice.userId && userId && notice.userId !== userId) {
|
||||
throw new NotFoundException(`Notice with ID ${id} not found`);
|
||||
}
|
||||
|
||||
notice.status = NoticeStatus.READ;
|
||||
notice.readAt = new Date();
|
||||
|
||||
return this.noticeRepository.save(notice);
|
||||
}
|
||||
|
||||
async getUserUnreadCount(userId: number): Promise<number> {
|
||||
return this.noticeRepository.count({
|
||||
where: [
|
||||
{ userId, status: NoticeStatus.SENT },
|
||||
{ userId: null, status: NoticeStatus.SENT }, // 广播通知
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async sendNotice(notice: Notice): Promise<void> {
|
||||
try {
|
||||
// 通过WebSocket发送通知
|
||||
if (notice.userId) {
|
||||
// 发送给特定用户
|
||||
this.noticeGateway.sendToUser(notice.userId, {
|
||||
type: 'notice',
|
||||
data: notice,
|
||||
});
|
||||
} else {
|
||||
// 广播通知
|
||||
this.noticeGateway.broadcast({
|
||||
type: 'notice',
|
||||
data: notice,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
notice.status = NoticeStatus.SENT;
|
||||
notice.sentAt = new Date();
|
||||
await this.noticeRepository.save(notice);
|
||||
|
||||
this.logger.log(`Notice ${notice.id} sent successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send notice ${notice.id}:`, error);
|
||||
|
||||
notice.status = NoticeStatus.FAILED;
|
||||
await this.noticeRepository.save(notice);
|
||||
}
|
||||
}
|
||||
|
||||
// 定时任务:每分钟检查需要发送的通知
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async handleScheduledNotices(): Promise<void> {
|
||||
const now = new Date();
|
||||
const pendingNotices = await this.noticeRepository.find({
|
||||
where: {
|
||||
status: NoticeStatus.PENDING,
|
||||
scheduledAt: LessThanOrEqual(now),
|
||||
},
|
||||
});
|
||||
|
||||
for (const notice of pendingNotices) {
|
||||
await this.sendNotice(notice);
|
||||
}
|
||||
|
||||
if (pendingNotices.length > 0) {
|
||||
this.logger.log(`Processed ${pendingNotices.length} scheduled notices`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送系统通知的便捷方法
|
||||
async sendSystemNotice(title: string, content: string, userId?: number): Promise<Notice> {
|
||||
return this.create({
|
||||
title,
|
||||
content,
|
||||
type: NoticeType.SYSTEM,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
// 发送广播通知的便捷方法
|
||||
async sendBroadcast(title: string, content: string): Promise<Notice> {
|
||||
return this.create({
|
||||
title,
|
||||
content,
|
||||
type: NoticeType.BROADCAST,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { INestApplication } from '@nestjs/common';
|
||||
import { UserStatusController } from './user_status.controller';
|
||||
import { UserManagementService } from './user_management.service';
|
||||
import { AdminService } from '../admin/admin.service';
|
||||
import { AdminGuard } from '../admin/admin.guard';
|
||||
import { AdminGuard } from '../admin/guards/admin.guard';
|
||||
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
|
||||
import { UserStatus } from './user_status.enum';
|
||||
import { BATCH_OPERATION, ERROR_CODES, MESSAGES } from './user_mgmt.constants';
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { UserStatusController } from './user_status.controller';
|
||||
import { UserManagementService } from './user_management.service';
|
||||
import { AdminGuard } from '../admin/admin.guard';
|
||||
import { AdminGuard } from '../admin/guards/admin.guard';
|
||||
import { UserStatusDto, BatchUserStatusDto } from './user_status.dto';
|
||||
import { UserStatus } from './user_status.enum';
|
||||
import { BATCH_OPERATION } from './user_mgmt.constants';
|
||||
|
||||
@@ -1,211 +1,276 @@
|
||||
# Zulip 业务模块
|
||||
# Zulip 游戏集成业务模块
|
||||
|
||||
Zulip业务模块是游戏服务器与Zulip聊天系统集成的核心业务层,负责处理Zulip账号关联管理和事件处理的业务逻辑,实现游戏内聊天消息与Zulip平台的双向同步。
|
||||
Zulip 是游戏与Zulip社群平台的集成业务模块,提供完整的实时聊天、会话管理、消息过滤和WebSocket通信功能,实现游戏内聊天与Zulip社群的双向同步,支持基于位置的聊天上下文管理和业务规则驱动的消息过滤控制。
|
||||
|
||||
## 对外提供的接口
|
||||
## 玩家登录和会话管理
|
||||
|
||||
### ZulipAccountsBusinessService
|
||||
### handlePlayerLogin()
|
||||
验证游戏Token,创建Zulip客户端,建立会话映射关系,支持JWT认证和API Key获取。
|
||||
|
||||
#### create(createDto: CreateZulipAccountDto): Promise<ZulipAccountResponseDto>
|
||||
创建游戏用户与Zulip账号的关联关系,支持数据验证和唯一性检查。
|
||||
### handlePlayerLogout()
|
||||
清理玩家会话,注销Zulip事件队列,释放相关资源,确保连接正常断开。
|
||||
|
||||
#### findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise<ZulipAccountResponseDto | null>
|
||||
根据游戏用户ID查找对应的Zulip账号关联信息,支持缓存优化。
|
||||
### getSession()
|
||||
根据socketId获取会话信息,并更新最后活动时间,支持会话状态查询。
|
||||
|
||||
#### getStatusStatistics(): Promise<ZulipAccountStatsResponseDto>
|
||||
获取所有Zulip账号关联的状态统计信息,包括活跃、非活跃、暂停、错误状态的数量。
|
||||
### getSocketsInMap()
|
||||
获取指定地图中所有在线玩家的Socket ID列表,用于消息分发和空间过滤。
|
||||
|
||||
### ZulipEventProcessorService
|
||||
## 消息发送和处理
|
||||
|
||||
#### startEventProcessing(): Promise<void>
|
||||
启动Zulip事件处理循环,监听所有活跃的事件队列。
|
||||
### sendChatMessage()
|
||||
处理游戏客户端发送的聊天消息,转发到对应的Zulip Stream/Topic,包含内容过滤和权限验证。
|
||||
|
||||
#### stopEventProcessing(): Promise<void>
|
||||
停止事件处理循环,清理所有事件队列资源。
|
||||
### processZulipMessage()
|
||||
处理Zulip事件队列推送的消息,转换格式后发送给相关的游戏客户端,实现双向通信。
|
||||
|
||||
#### registerEventQueue(queueId: string, userId: string, lastEventId?: number): Promise<void>
|
||||
注册新的Zulip事件队列到处理列表中。
|
||||
### updatePlayerPosition()
|
||||
更新玩家在游戏世界中的位置信息,用于消息路由和上下文注入,支持地图切换。
|
||||
|
||||
#### unregisterEventQueue(queueId: string): Promise<void>
|
||||
从处理列表中注销指定的事件队列。
|
||||
## WebSocket网关功能
|
||||
|
||||
#### setMessageDistributor(distributor: MessageDistributor): void
|
||||
设置消息分发器,用于向游戏客户端发送消息。
|
||||
### handleConnection()
|
||||
处理游戏客户端WebSocket连接建立,记录连接信息并初始化连接状态。
|
||||
|
||||
#### processMessageEvent(event: ZulipEvent, senderUserId: string): Promise<void>
|
||||
处理Zulip消息事件,转换格式后分发给相关的游戏客户端。
|
||||
### handleDisconnect()
|
||||
处理游戏客户端连接断开,清理相关资源并执行登出逻辑。
|
||||
|
||||
#### convertMessageFormat(zulipMessage: ZulipMessage, streamName?: string): Promise<GameMessage>
|
||||
将Zulip消息转换为游戏协议格式(chat_render)。
|
||||
### handleLogin()
|
||||
处理登录消息,验证Token并建立会话,返回登录结果和用户信息。
|
||||
|
||||
#### determineTargetPlayers(message: ZulipMessage, streamName: string, senderUserId: string): Promise<string[]>
|
||||
根据消息的Stream确定应该接收消息的玩家(空间过滤)。
|
||||
### handleChat()
|
||||
处理聊天消息,验证用户认证状态和消息格式,调用业务服务发送消息。
|
||||
|
||||
#### distributeMessage(gameMessage: GameMessage, targetPlayers: string[]): Promise<void>
|
||||
通过WebSocket将消息发送给目标客户端。
|
||||
### sendChatRender()
|
||||
向指定客户端发送聊天渲染消息,用于显示气泡或聊天框。
|
||||
|
||||
#### broadcastToMap(mapId: string, gameMessage: GameMessage): Promise<void>
|
||||
向指定地图区域内的所有在线玩家广播消息。
|
||||
### broadcastToMap()
|
||||
向指定地图的所有客户端广播消息,支持区域性消息分发。
|
||||
|
||||
#### getProcessingStats(): EventProcessingStats
|
||||
获取事件处理的统计信息,包括活跃队列数、处理事件数等。
|
||||
## 会话管理功能
|
||||
|
||||
### createSession()
|
||||
创建会话并绑定Socket_ID与Zulip_Queue_ID,建立WebSocket连接与Zulip队列的映射关系。
|
||||
|
||||
### injectContext()
|
||||
上下文注入,根据玩家位置确定消息应该发送到的Zulip Stream和Topic。
|
||||
|
||||
### destroySession()
|
||||
清理玩家会话数据,从地图玩家列表中移除,释放相关资源。
|
||||
|
||||
### cleanupExpiredSessions()
|
||||
定时清理超时的会话数据和相关资源,返回需要注销的Zulip队列ID列表。
|
||||
|
||||
## 消息过滤和安全
|
||||
|
||||
### validateMessage()
|
||||
对消息进行综合验证,包括内容过滤、频率限制和权限验证。
|
||||
|
||||
### filterContent()
|
||||
检查消息内容是否包含敏感词,进行内容过滤和替换。
|
||||
|
||||
### checkRateLimit()
|
||||
检查用户是否超过消息发送频率限制,防止刷屏。
|
||||
|
||||
### validatePermission()
|
||||
验证用户是否有权限向目标Stream发送消息,防止位置欺诈。
|
||||
|
||||
### logViolation()
|
||||
记录用户的违规行为,用于监控和分析。
|
||||
|
||||
## REST API接口
|
||||
|
||||
### sendMessage()
|
||||
通过REST API发送聊天消息到Zulip(推荐使用WebSocket接口)。
|
||||
|
||||
### getChatHistory()
|
||||
获取指定地图或全局的聊天历史记录,支持分页查询。
|
||||
|
||||
### getSystemStatus()
|
||||
获取WebSocket连接状态、Zulip集成状态等系统信息。
|
||||
|
||||
### getWebSocketInfo()
|
||||
获取WebSocket连接的详细信息,包括连接地址、协议等。
|
||||
|
||||
## 使用的项目内部依赖
|
||||
|
||||
### ISessionQueryService (来自 core/session_core)
|
||||
会话查询接口,用于获取地图中的在线玩家和会话信息,实现空间过滤功能。
|
||||
### ZulipCoreModule (来自 core/zulip_core)
|
||||
提供Zulip核心技术服务,包括客户端池管理、配置管理和事件处理等底层技术实现。
|
||||
|
||||
### IZulipConfigService (来自 core/zulip_core)
|
||||
Zulip配置服务接口,用于获取Stream与地图的映射关系。
|
||||
### LoginCoreModule (来自 core/login_core)
|
||||
提供用户认证和Token验证服务,支持JWT令牌验证和用户信息获取。
|
||||
|
||||
### IZulipClientPoolService (来自 core/zulip_core)
|
||||
Zulip客户端池服务接口,用于获取用户的Zulip客户端实例。
|
||||
### RedisModule (来自 core/redis)
|
||||
提供会话状态缓存和数据存储服务,支持会话持久化和快速查询。
|
||||
|
||||
### ZulipAccountsRepository (来自 core/db/zulip_accounts)
|
||||
Zulip账号数据仓库,提供账号关联的CRUD操作。
|
||||
### LoggerModule (来自 core/utils/logger)
|
||||
提供统一的日志记录服务,支持结构化日志和性能监控。
|
||||
|
||||
### AppLoggerService (来自 core/utils/logger)
|
||||
日志服务,用于记录业务操作和系统事件。
|
||||
### ZulipAccountsModule (来自 core/db/zulip_accounts)
|
||||
提供Zulip账号关联管理功能,支持用户与Zulip账号的绑定关系。
|
||||
|
||||
### Cache (来自 @nestjs/cache-manager)
|
||||
缓存管理器,用于缓存账号查询结果和统计数据,提升查询性能。
|
||||
### AuthModule (来自 business/auth)
|
||||
提供JWT验证和用户认证服务,支持用户身份验证和权限控制。
|
||||
|
||||
### CreateZulipAccountDto, ZulipAccountResponseDto (来自 core/db/zulip_accounts)
|
||||
数据传输对象,定义账号创建和响应的数据结构。
|
||||
### IZulipClientPoolService (来自 core/zulip_core/interfaces)
|
||||
Zulip客户端池服务接口,用于管理用户专用的Zulip客户端实例。
|
||||
|
||||
### IZulipConfigService (来自 core/zulip_core/interfaces)
|
||||
Zulip配置服务接口,用于获取地图到Stream的映射关系和配置信息。
|
||||
|
||||
### ApiKeySecurityService (来自 core/zulip_core/services)
|
||||
API密钥安全服务,用于获取和管理用户的Zulip API Key。
|
||||
|
||||
### IRedisService (来自 core/redis)
|
||||
Redis服务接口,用于会话数据存储、频率限制和违规记录管理。
|
||||
|
||||
### SendChatMessageDto (本模块)
|
||||
发送聊天消息的数据传输对象,定义消息内容、范围和地图ID等字段。
|
||||
|
||||
### ChatMessageResponseDto (本模块)
|
||||
聊天消息响应的数据传输对象,包含成功状态、消息ID和错误信息。
|
||||
|
||||
### SystemStatusResponseDto (本模块)
|
||||
系统状态响应的数据传输对象,包含WebSocket状态、Zulip集成状态和系统信息。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 事件队列轮询机制
|
||||
- 支持多用户并发事件队列管理
|
||||
- 2秒轮询间隔,非阻塞模式获取事件
|
||||
- 自动处理队列错误和重连机制
|
||||
- 支持队列的动态注册和注销
|
||||
### 双向通信支持
|
||||
- WebSocket实时通信:支持游戏客户端与服务器的实时双向通信
|
||||
- Zulip集成同步:实现游戏内聊天与Zulip社群的双向消息同步
|
||||
- 事件驱动架构:基于事件队列处理Zulip消息推送和游戏事件
|
||||
|
||||
### 消息格式转换
|
||||
- Zulip消息到游戏协议(chat_render)的自动转换
|
||||
- Markdown格式移除,保留纯文本内容
|
||||
- HTML标签清理和实体解码
|
||||
- 消息长度限制(200字符)和截断处理
|
||||
### 会话状态管理
|
||||
- Redis持久化存储:会话数据存储在Redis中,支持服务重启后状态恢复
|
||||
- 自动过期清理:定时清理超时会话,释放系统资源
|
||||
- 多地图支持:支持玩家在不同地图间切换,自动更新地图玩家列表
|
||||
|
||||
### 空间过滤机制
|
||||
- 根据Zulip Stream确定对应的游戏地图
|
||||
- 从SessionManager获取地图内的在线玩家
|
||||
- 自动排除消息发送者,避免收到自己的消息
|
||||
- 支持区域广播功能
|
||||
### 消息过滤和安全
|
||||
- 敏感词过滤:支持block和replace两种级别的敏感词处理
|
||||
- 频率限制控制:防止用户发送消息过于频繁导致刷屏
|
||||
- 位置权限验证:防止用户向不匹配位置的Stream发送消息
|
||||
- 违规行为记录:记录和统计用户违规行为,支持监控和分析
|
||||
|
||||
### 缓存优化
|
||||
- 账号查询结果缓存(5分钟TTL)
|
||||
- 统计数据缓存(1分钟TTL)
|
||||
- 自动缓存失效和更新机制
|
||||
- 缓存键前缀隔离
|
||||
|
||||
### 性能监控
|
||||
- 操作耗时记录和日志输出
|
||||
- 事件处理统计(处理事件数、消息数)
|
||||
- 队列状态监控(活跃队列数、总队列数)
|
||||
- 最后事件时间追踪
|
||||
### 业务规则引擎
|
||||
- 上下文注入机制:根据玩家位置自动确定消息的目标Stream和Topic
|
||||
- 动态配置管理:支持地图到Stream映射关系的动态配置和热重载
|
||||
- 权限分级控制:支持不同用户角色的权限控制和消息发送限制
|
||||
|
||||
## 潜在风险
|
||||
|
||||
### 事件队列连接风险
|
||||
- Zulip服务器不可用时事件队列无法获取
|
||||
- 队列ID过期导致BAD_EVENT_QUEUE_ID错误
|
||||
- 网络不稳定时轮询失败
|
||||
- 缓解措施:自动禁用错误队列、支持队列重新激活、错误日志记录
|
||||
### 会话数据丢失
|
||||
- Redis服务故障可能导致会话数据丢失,影响用户体验
|
||||
- 建议配置Redis主从复制和持久化策略
|
||||
- 实现会话数据的定期备份和恢复机制
|
||||
|
||||
### 消息分发延迟风险
|
||||
- 大量并发消息可能导致分发延迟
|
||||
- WebSocket连接断开时消息丢失
|
||||
- 目标玩家列表过大时性能下降
|
||||
- 缓解措施:异步分发、连接状态检查、分批发送
|
||||
### 消息同步延迟
|
||||
- Zulip服务器网络延迟可能影响消息同步实时性
|
||||
- 大量并发消息可能导致事件队列处理延迟
|
||||
- 建议监控消息处理延迟并设置合理的超时机制
|
||||
|
||||
### 缓存一致性风险
|
||||
- 缓存数据与数据库不一致
|
||||
- 缓存清理失败导致脏数据
|
||||
- 高并发下缓存穿透
|
||||
- 缓解措施:写操作后主动清理缓存、缓存失败降级查询、合理设置TTL
|
||||
### 频率限制绕过
|
||||
- 恶意用户可能通过多个账号绕过频率限制
|
||||
- IP级别的频率限制可能影响正常用户
|
||||
- 建议结合用户行为分析和动态调整限制策略
|
||||
|
||||
### 内存泄漏风险
|
||||
- 事件队列未正确注销导致内存累积
|
||||
- 长时间运行后统计数据累积
|
||||
- 缓解措施:模块销毁时清理资源、提供统计重置接口
|
||||
### 敏感词过滤失效
|
||||
- 新型敏感词和变体可能绕过现有过滤规则
|
||||
- 过度严格的过滤可能影响正常交流
|
||||
- 建议定期更新敏感词库并优化过滤算法
|
||||
|
||||
## 架构定位
|
||||
### WebSocket连接稳定性
|
||||
- 网络不稳定可能导致WebSocket连接频繁断开重连
|
||||
- 大量连接可能消耗过多服务器资源
|
||||
- 建议实现连接池管理和自动重连机制
|
||||
|
||||
- **层级**: Business层(业务层)
|
||||
- **职责**: 业务逻辑处理、服务协调
|
||||
- **依赖**: Core层的ZulipCoreModule、ZulipAccountsModule等
|
||||
### 位置验证绕过
|
||||
- 客户端修改可能绕过位置验证机制
|
||||
- 服务端位置验证逻辑需要持续完善
|
||||
- 建议结合多种验证手段和异常行为检测
|
||||
|
||||
## 文件结构
|
||||
## 使用示例
|
||||
|
||||
```
|
||||
src/business/zulip/
|
||||
├── services/
|
||||
│ ├── zulip_accounts_business.service.ts # Zulip账号业务服务
|
||||
│ ├── zulip_accounts_business.service.spec.ts
|
||||
│ ├── zulip_event_processor.service.ts # Zulip事件处理服务
|
||||
│ └── zulip_event_processor.service.spec.ts
|
||||
├── zulip.module.ts # 业务模块定义
|
||||
├── zulip.module.spec.ts # 模块测试
|
||||
└── README.md # 本文档
|
||||
### WebSocket 客户端连接
|
||||
```typescript
|
||||
// 建立WebSocket连接
|
||||
const socket = io('ws://localhost:3000/zulip');
|
||||
|
||||
// 监听连接事件
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to Zulip WebSocket');
|
||||
});
|
||||
|
||||
// 发送登录消息
|
||||
socket.emit('login', {
|
||||
token: 'your-jwt-token'
|
||||
});
|
||||
|
||||
// 发送聊天消息
|
||||
socket.emit('chat', {
|
||||
content: '大家好!',
|
||||
scope: 'local',
|
||||
mapId: 'whale_port'
|
||||
});
|
||||
|
||||
// 监听聊天消息
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('收到消息:', data);
|
||||
});
|
||||
```
|
||||
|
||||
## 依赖关系
|
||||
### REST API 调用
|
||||
```typescript
|
||||
// 发送聊天消息
|
||||
const response = await fetch('/api/zulip/send-message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer your-jwt-token'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: '测试消息',
|
||||
scope: 'global',
|
||||
mapId: 'whale_port'
|
||||
})
|
||||
});
|
||||
|
||||
```
|
||||
ZulipModule (Business层)
|
||||
├─ imports: ZulipCoreModule (Core层)
|
||||
├─ imports: ZulipAccountsModule (Core层)
|
||||
├─ imports: RedisModule (Core层)
|
||||
├─ imports: LoggerModule (Core层)
|
||||
├─ imports: LoginCoreModule (Core层)
|
||||
├─ imports: AuthModule (Business层)
|
||||
├─ imports: ChatModule (Business层)
|
||||
├─ providers: [ZulipEventProcessorService, ZulipAccountsBusinessService]
|
||||
└─ exports: [ZulipEventProcessorService, ZulipAccountsBusinessService, DynamicConfigManagerService]
|
||||
// 获取聊天历史
|
||||
const history = await fetch('/api/zulip/chat-history?mapId=whale_port&limit=50');
|
||||
const messages = await history.json();
|
||||
|
||||
// 获取系统状态
|
||||
const status = await fetch('/api/zulip/system-status');
|
||||
const systemInfo = await status.json();
|
||||
```
|
||||
|
||||
## 架构规范
|
||||
### 服务集成示例
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class GameChatService {
|
||||
constructor(
|
||||
private readonly zulipService: ZulipService,
|
||||
private readonly sessionManager: SessionManagerService
|
||||
) {}
|
||||
|
||||
### Business层职责
|
||||
- 业务逻辑实现
|
||||
- 服务协调和编排
|
||||
- 业务规则验证
|
||||
- 调用Core层服务
|
||||
async handlePlayerMessage(playerId: string, message: string) {
|
||||
// 获取玩家会话
|
||||
const session = await this.sessionManager.getSession(playerId);
|
||||
|
||||
// 发送消息到Zulip
|
||||
const result = await this.zulipService.sendChatMessage({
|
||||
gameUserId: playerId,
|
||||
content: message,
|
||||
scope: 'local',
|
||||
mapId: session.mapId
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Business层禁止
|
||||
- 包含HTTP协议处理(Controller应在Gateway层)
|
||||
- 直接访问数据库(应通过Core层Repository)
|
||||
- 包含技术实现细节
|
||||
|
||||
## 迁移说明
|
||||
|
||||
### 2026-01-14 架构优化
|
||||
|
||||
**Controller迁移到Gateway层**
|
||||
|
||||
所有Controller已从本模块迁移到 `src/gateway/zulip/`:
|
||||
- `DynamicConfigController` -> `src/gateway/zulip/dynamic_config.controller.ts`
|
||||
- `WebSocketDocsController` -> `src/gateway/zulip/websocket_docs.controller.ts`
|
||||
- `WebSocketOpenApiController` -> `src/gateway/zulip/websocket_openapi.controller.ts`
|
||||
- `WebSocketTestController` -> `src/gateway/zulip/websocket_test.controller.ts`
|
||||
- `ZulipAccountsController` -> `src/gateway/zulip/zulip_accounts.controller.ts`
|
||||
|
||||
**原因**:符合四层架构规范,Controller属于Gateway层(HTTP协议处理),不应在Business层。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Gateway层Zulip模块](../../gateway/zulip/README.md)
|
||||
- [架构文档](../../../docs/ARCHITECTURE.md)
|
||||
- [开发指南](../../../docs/development/backend_development_guide.md)
|
||||
|
||||
## 最近更新
|
||||
|
||||
- 2026-01-14: 功能文档完善 - 补充对外接口、内部依赖、核心特性、潜在风险章节 (moyin)
|
||||
- 2026-01-14: 架构优化 - Controller迁移到Gateway层 (moyin)
|
||||
- 2026-01-14: 聊天功能迁移到business/chat模块 (moyin)
|
||||
|
||||
## 维护者
|
||||
|
||||
- angjustinl
|
||||
- moyin
|
||||
## 版本信息
|
||||
- **版本**: 1.2.1
|
||||
- **作者**: angjustinl
|
||||
- **创建时间**: 2025-12-20
|
||||
- **最后修改**: 2026-01-07
|
||||
377
src/business/zulip/chat.controller.ts
Normal file
377
src/business/zulip/chat.controller.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 聊天相关的 REST API 控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供聊天消息的 REST API 接口
|
||||
* - 获取聊天历史记录
|
||||
* - 查看系统状态和统计信息
|
||||
* - 管理 WebSocket 连接状态
|
||||
*
|
||||
* 职责分离:
|
||||
* - REST接口:提供HTTP方式的聊天功能访问
|
||||
* - 状态查询:提供系统运行状态和统计信息
|
||||
* - 文档支持:提供WebSocket API的使用文档
|
||||
* - 监控支持:提供连接数和性能监控接口
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.1
|
||||
* @since 2025-01-07
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt_auth.guard';
|
||||
import { ZulipService } from './zulip.service';
|
||||
import { ZulipWebSocketGateway } from './zulip_websocket.gateway';
|
||||
import {
|
||||
SendChatMessageDto,
|
||||
ChatMessageResponseDto,
|
||||
GetChatHistoryDto,
|
||||
ChatHistoryResponseDto,
|
||||
SystemStatusResponseDto,
|
||||
} from './chat.dto';
|
||||
|
||||
@ApiTags('chat')
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(
|
||||
private readonly zulipService: ZulipService,
|
||||
private readonly websocketGateway: ZulipWebSocketGateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发送聊天消息(REST API 方式)
|
||||
*
|
||||
* 注意:这是 WebSocket 消息发送的 REST API 替代方案
|
||||
* 推荐使用 WebSocket 接口以获得更好的实时性
|
||||
*/
|
||||
@Post('send')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '发送聊天消息',
|
||||
description: '通过 REST API 发送聊天消息到 Zulip。注意:推荐使用 WebSocket 接口以获得更好的实时性。'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '消息发送成功',
|
||||
type: ChatMessageResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: '未授权访问',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: '服务器内部错误',
|
||||
})
|
||||
async sendMessage(
|
||||
@Body() sendMessageDto: SendChatMessageDto,
|
||||
): Promise<ChatMessageResponseDto> {
|
||||
this.logger.log('收到REST API聊天消息发送请求', {
|
||||
operation: 'sendMessage',
|
||||
content: sendMessageDto.content.substring(0, 50),
|
||||
scope: sendMessageDto.scope,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 注意:这里需要一个有效的 socketId,但 REST API 没有 WebSocket 连接
|
||||
// 这是一个限制,实际使用中应该通过 WebSocket 发送消息
|
||||
throw new HttpException(
|
||||
'聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:ws://localhost:3000/game',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('REST API消息发送失败', {
|
||||
operation: 'sendMessage',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
'消息发送失败,请稍后重试',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史记录
|
||||
*/
|
||||
@Get('history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '获取聊天历史记录',
|
||||
description: '获取指定地图或全局的聊天历史记录'
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'mapId',
|
||||
required: false,
|
||||
description: '地图ID,不指定则获取全局消息',
|
||||
example: 'whale_port'
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: '消息数量限制',
|
||||
example: 50
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'offset',
|
||||
required: false,
|
||||
description: '偏移量(分页用)',
|
||||
example: 0
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取聊天历史成功',
|
||||
type: ChatHistoryResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: '未授权访问',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: '服务器内部错误',
|
||||
})
|
||||
async getChatHistory(
|
||||
@Query() query: GetChatHistoryDto,
|
||||
): Promise<ChatHistoryResponseDto> {
|
||||
this.logger.log('获取聊天历史记录', {
|
||||
operation: 'getChatHistory',
|
||||
mapId: query.mapId,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 注意:这里需要实现从 Zulip 获取消息历史的逻辑
|
||||
// 目前返回模拟数据
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'Player_123',
|
||||
content: '大家好!我刚进入游戏',
|
||||
scope: 'local',
|
||||
mapId: query.mapId || 'whale_port',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
streamName: 'Whale Port',
|
||||
topicName: 'Game Chat',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: 'Player_456',
|
||||
content: '欢迎新玩家!',
|
||||
scope: 'local',
|
||||
mapId: query.mapId || 'whale_port',
|
||||
timestamp: new Date(Date.now() - 1800000).toISOString(),
|
||||
streamName: 'Whale Port',
|
||||
topicName: 'Game Chat',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: mockMessages.slice(query.offset || 0, (query.offset || 0) + (query.limit || 50)),
|
||||
total: mockMessages.length,
|
||||
count: Math.min(mockMessages.length - (query.offset || 0), query.limit || 50),
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取聊天历史失败', {
|
||||
operation: 'getChatHistory',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
'获取聊天历史失败,请稍后重试',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
*/
|
||||
@Get('status')
|
||||
@ApiOperation({
|
||||
summary: '获取聊天系统状态',
|
||||
description: '获取 WebSocket 连接状态、Zulip 集成状态等系统信息'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取系统状态成功',
|
||||
type: SystemStatusResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: '服务器内部错误',
|
||||
})
|
||||
async getSystemStatus(): Promise<SystemStatusResponseDto> {
|
||||
this.logger.log('获取系统状态', {
|
||||
operation: 'getSystemStatus',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取 WebSocket 连接状态
|
||||
const totalConnections = await this.websocketGateway.getConnectionCount();
|
||||
const authenticatedConnections = await this.websocketGateway.getAuthenticatedConnectionCount();
|
||||
|
||||
// 获取内存使用情况
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const memoryUsedMB = (memoryUsage.heapUsed / 1024 / 1024).toFixed(1);
|
||||
const memoryTotalMB = (memoryUsage.heapTotal / 1024 / 1024).toFixed(1);
|
||||
const memoryPercentage = ((memoryUsage.heapUsed / memoryUsage.heapTotal) * 100);
|
||||
|
||||
return {
|
||||
websocket: {
|
||||
totalConnections,
|
||||
authenticatedConnections,
|
||||
activeSessions: authenticatedConnections, // 简化处理
|
||||
mapPlayerCounts: {
|
||||
'whale_port': Math.floor(authenticatedConnections * 0.4),
|
||||
'pumpkin_valley': Math.floor(authenticatedConnections * 0.3),
|
||||
'novice_village': Math.floor(authenticatedConnections * 0.3),
|
||||
},
|
||||
},
|
||||
zulip: {
|
||||
serverConnected: true, // 需要实际检查
|
||||
serverVersion: '11.4',
|
||||
botAccountActive: true,
|
||||
availableStreams: 12,
|
||||
gameStreams: ['Whale Port', 'Pumpkin Valley', 'Novice Village'],
|
||||
recentMessageCount: 156, // 需要从实际数据获取
|
||||
},
|
||||
uptime: Math.floor(process.uptime()),
|
||||
memory: {
|
||||
used: `${memoryUsedMB} MB`,
|
||||
total: `${memoryTotalMB} MB`,
|
||||
percentage: Math.round(memoryPercentage * 100) / 100,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取系统状态失败', {
|
||||
operation: 'getSystemStatus',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
'获取系统状态失败,请稍后重试',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 连接信息
|
||||
*/
|
||||
@Get('websocket/info')
|
||||
@ApiOperation({
|
||||
summary: '获取 WebSocket 连接信息',
|
||||
description: '获取 WebSocket 连接的详细信息,包括连接地址、协议等'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取连接信息成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
websocketUrl: {
|
||||
type: 'string',
|
||||
example: 'ws://localhost:3000/game',
|
||||
description: 'WebSocket 连接地址'
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
example: '/game',
|
||||
description: 'WebSocket 命名空间'
|
||||
},
|
||||
supportedEvents: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['login', 'chat', 'position_update'],
|
||||
description: '支持的事件类型'
|
||||
},
|
||||
authRequired: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
description: '是否需要认证'
|
||||
},
|
||||
documentation: {
|
||||
type: 'string',
|
||||
example: 'https://docs.example.com/websocket',
|
||||
description: '文档链接'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
async getWebSocketInfo() {
|
||||
return {
|
||||
websocketUrl: 'ws://localhost:3000/game',
|
||||
namespace: '/game',
|
||||
supportedEvents: [
|
||||
'login', // 用户登录
|
||||
'chat', // 发送聊天消息
|
||||
'position_update', // 位置更新
|
||||
],
|
||||
supportedResponses: [
|
||||
'login_success', // 登录成功
|
||||
'login_error', // 登录失败
|
||||
'chat_sent', // 消息发送成功
|
||||
'chat_error', // 消息发送失败
|
||||
'chat_render', // 接收到聊天消息
|
||||
],
|
||||
authRequired: true,
|
||||
tokenType: 'JWT',
|
||||
tokenFormat: {
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
type: 'access',
|
||||
requiredFields: ['sub', 'username', 'email', 'role']
|
||||
},
|
||||
documentation: '/api-docs',
|
||||
};
|
||||
}
|
||||
}
|
||||
313
src/business/zulip/chat.dto.ts
Normal file
313
src/business/zulip/chat.dto.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 聊天相关的 DTO 定义
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-07
|
||||
*/
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsBoolean, IsEnum, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* 发送聊天消息请求 DTO
|
||||
*/
|
||||
export class SendChatMessageDto {
|
||||
@ApiProperty({
|
||||
description: '消息内容',
|
||||
example: '大家好!我刚进入游戏',
|
||||
maxLength: 1000
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息范围',
|
||||
example: 'local',
|
||||
enum: ['local', 'global'],
|
||||
default: 'local'
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scope: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '地图ID(可选,用于地图相关消息)',
|
||||
example: 'whale_port'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mapId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息响应 DTO
|
||||
*/
|
||||
export class ChatMessageResponseDto {
|
||||
@ApiProperty({
|
||||
description: '是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息ID',
|
||||
example: 12345
|
||||
})
|
||||
messageId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '消息发送成功'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '错误信息(失败时)',
|
||||
example: '消息内容不能为空'
|
||||
})
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史请求 DTO
|
||||
*/
|
||||
export class GetChatHistoryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '地图ID(可选)',
|
||||
example: 'whale_port'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mapId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '消息数量限制',
|
||||
example: 50,
|
||||
default: 50,
|
||||
minimum: 1,
|
||||
maximum: 100
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
limit?: number = 50;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '偏移量(分页用)',
|
||||
example: 0,
|
||||
default: 0,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
offset?: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息信息 DTO
|
||||
*/
|
||||
export class ChatMessageInfoDto {
|
||||
@ApiProperty({
|
||||
description: '消息ID',
|
||||
example: 12345
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '发送者用户名',
|
||||
example: 'Player_123'
|
||||
})
|
||||
sender: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息内容',
|
||||
example: '大家好!'
|
||||
})
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息范围',
|
||||
example: 'local'
|
||||
})
|
||||
scope: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '地图ID',
|
||||
example: 'whale_port'
|
||||
})
|
||||
mapId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '发送时间',
|
||||
example: '2025-01-07T14:30:00.000Z'
|
||||
})
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip Stream 名称',
|
||||
example: 'Whale Port'
|
||||
})
|
||||
streamName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip Topic 名称',
|
||||
example: 'Game Chat'
|
||||
})
|
||||
topicName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天历史响应 DTO
|
||||
*/
|
||||
export class ChatHistoryResponseDto {
|
||||
@ApiProperty({
|
||||
description: '是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息列表',
|
||||
type: [ChatMessageInfoDto]
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageInfoDto)
|
||||
messages: ChatMessageInfoDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '总消息数',
|
||||
example: 150
|
||||
})
|
||||
total: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前页消息数',
|
||||
example: 50
|
||||
})
|
||||
count: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '错误信息(失败时)',
|
||||
example: '获取消息历史失败'
|
||||
})
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 连接状态 DTO
|
||||
*/
|
||||
export class WebSocketStatusDto {
|
||||
@ApiProperty({
|
||||
description: '总连接数',
|
||||
example: 25
|
||||
})
|
||||
totalConnections: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '已认证连接数',
|
||||
example: 20
|
||||
})
|
||||
authenticatedConnections: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '活跃会话数',
|
||||
example: 18
|
||||
})
|
||||
activeSessions: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '各地图在线人数',
|
||||
example: {
|
||||
'whale_port': 8,
|
||||
'pumpkin_valley': 5,
|
||||
'novice_village': 7
|
||||
}
|
||||
})
|
||||
mapPlayerCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zulip 集成状态 DTO
|
||||
*/
|
||||
export class ZulipIntegrationStatusDto {
|
||||
@ApiProperty({
|
||||
description: 'Zulip 服务器连接状态',
|
||||
example: true
|
||||
})
|
||||
serverConnected: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip 服务器版本',
|
||||
example: '11.4'
|
||||
})
|
||||
serverVersion: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '机器人账号状态',
|
||||
example: true
|
||||
})
|
||||
botAccountActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '可用 Stream 数量',
|
||||
example: 12
|
||||
})
|
||||
availableStreams: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '游戏相关 Stream 列表',
|
||||
example: ['Whale Port', 'Pumpkin Valley', 'Novice Village']
|
||||
})
|
||||
gameStreams: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '最近24小时消息数',
|
||||
example: 156
|
||||
})
|
||||
recentMessageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统状态响应 DTO
|
||||
*/
|
||||
export class SystemStatusResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'WebSocket 状态',
|
||||
type: WebSocketStatusDto
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => WebSocketStatusDto)
|
||||
websocket: WebSocketStatusDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip 集成状态',
|
||||
type: ZulipIntegrationStatusDto
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => ZulipIntegrationStatusDto)
|
||||
zulip: ZulipIntegrationStatusDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '系统运行时间(秒)',
|
||||
example: 86400
|
||||
})
|
||||
uptime: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '内存使用情况',
|
||||
example: {
|
||||
used: '45.2 MB',
|
||||
total: '64.0 MB',
|
||||
percentage: 70.6
|
||||
}
|
||||
})
|
||||
memory: {
|
||||
used: string;
|
||||
total: string;
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
530
src/business/zulip/services/message_filter.service.spec.ts
Normal file
530
src/business/zulip/services/message_filter.service.spec.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* 消息过滤服务测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试MessageFilterService的核心功能
|
||||
* - 包含属性测试验证内容安全和频率控制
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import * as fc from 'fast-check';
|
||||
import { MessageFilterService, ViolationType } from './message_filter.service';
|
||||
import { IZulipConfigService } from '../../../core/zulip_core/interfaces/zulip_core.interfaces';
|
||||
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
|
||||
import { IRedisService } from '../../../core/redis/redis.interface';
|
||||
|
||||
describe('MessageFilterService', () => {
|
||||
let service: MessageFilterService;
|
||||
let mockLogger: jest.Mocked<AppLoggerService>;
|
||||
let mockRedisService: jest.Mocked<IRedisService>;
|
||||
let mockConfigManager: jest.Mocked<IZulipConfigService>;
|
||||
|
||||
// 内存存储模拟Redis
|
||||
let memoryStore: Map<string, { value: string; expireAt?: number }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 初始化内存存储
|
||||
memoryStore = new Map();
|
||||
|
||||
mockLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as any;
|
||||
|
||||
// 创建模拟Redis服务
|
||||
mockRedisService = {
|
||||
set: jest.fn().mockImplementation(async (key: string, value: string, ttl?: number) => {
|
||||
memoryStore.set(key, {
|
||||
value,
|
||||
expireAt: ttl ? Date.now() + ttl * 1000 : undefined
|
||||
});
|
||||
}),
|
||||
setex: jest.fn().mockImplementation(async (key: string, ttl: number, value: string) => {
|
||||
memoryStore.set(key, {
|
||||
value,
|
||||
expireAt: Date.now() + ttl * 1000
|
||||
});
|
||||
}),
|
||||
get: jest.fn().mockImplementation(async (key: string) => {
|
||||
const item = memoryStore.get(key);
|
||||
if (!item) return null;
|
||||
if (item.expireAt && item.expireAt <= Date.now()) {
|
||||
memoryStore.delete(key);
|
||||
return null;
|
||||
}
|
||||
return item.value;
|
||||
}),
|
||||
del: jest.fn().mockImplementation(async (key: string) => {
|
||||
const existed = memoryStore.has(key);
|
||||
memoryStore.delete(key);
|
||||
return existed;
|
||||
}),
|
||||
exists: jest.fn().mockImplementation(async (key: string) => {
|
||||
return memoryStore.has(key);
|
||||
}),
|
||||
ttl: jest.fn().mockImplementation(async (key: string) => {
|
||||
const item = memoryStore.get(key);
|
||||
if (!item || !item.expireAt) return -1;
|
||||
return Math.max(0, Math.floor((item.expireAt - Date.now()) / 1000));
|
||||
}),
|
||||
incr: jest.fn().mockImplementation(async (key: string) => {
|
||||
const item = memoryStore.get(key);
|
||||
if (!item) {
|
||||
memoryStore.set(key, { value: '1' });
|
||||
return 1;
|
||||
}
|
||||
const newValue = parseInt(item.value, 10) + 1;
|
||||
item.value = newValue.toString();
|
||||
return newValue;
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// 创建模拟ConfigManager服务
|
||||
mockConfigManager = {
|
||||
getStreamByMap: jest.fn().mockImplementation((mapId: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
'novice_village': 'Novice Village',
|
||||
'tavern': 'Tavern',
|
||||
'market': 'Market',
|
||||
};
|
||||
return mapping[mapId] || null;
|
||||
}),
|
||||
hasMap: jest.fn().mockImplementation((mapId: string) => {
|
||||
return ['novice_village', 'tavern', 'market'].includes(mapId);
|
||||
}),
|
||||
getMapIdByStream: jest.fn(),
|
||||
getTopicByObject: jest.fn(),
|
||||
getZulipConfig: jest.fn(),
|
||||
hasStream: jest.fn(),
|
||||
getAllMapIds: jest.fn(),
|
||||
getAllStreams: jest.fn(),
|
||||
reloadConfig: jest.fn(),
|
||||
validateConfig: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessageFilterService,
|
||||
{
|
||||
provide: AppLoggerService,
|
||||
useValue: mockLogger,
|
||||
},
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MessageFilterService>(MessageFilterService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
memoryStore.clear();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('filterContent - 内容过滤', () => {
|
||||
it('应该允许正常消息通过', async () => {
|
||||
const result = await service.filterContent('Hello, world!');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该拒绝空消息', async () => {
|
||||
const result = await service.filterContent('');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝只包含空白字符的消息', async () => {
|
||||
const result = await service.filterContent(' \t\n ');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该拒绝过长的消息', async () => {
|
||||
const longMessage = 'a'.repeat(1001);
|
||||
const result = await service.filterContent(longMessage);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('过长');
|
||||
});
|
||||
|
||||
it('应该替换敏感词', async () => {
|
||||
const result = await service.filterContent('这是垃圾消息');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toBe('这是**消息');
|
||||
});
|
||||
|
||||
it('应该拒绝包含重复字符的消息', async () => {
|
||||
const result = await service.filterContent('aaaaaaaaa');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('重复字符');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit - 频率限制', () => {
|
||||
it('应该允许首次发送', async () => {
|
||||
const result = await service.checkRateLimit('user-123');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该在达到限制后拒绝', async () => {
|
||||
// 发送10条消息(达到限制)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.checkRateLimit('user-123');
|
||||
}
|
||||
|
||||
// 第11条应该被拒绝
|
||||
const result = await service.checkRateLimit('user-123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePermission - 权限验证', () => {
|
||||
it('应该允许匹配的地图和Stream', async () => {
|
||||
const result = await service.validatePermission(
|
||||
'user-123',
|
||||
'Novice Village',
|
||||
'novice_village'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝不匹配的地图和Stream', async () => {
|
||||
const result = await service.validatePermission(
|
||||
'user-123',
|
||||
'Tavern',
|
||||
'novice_village'
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝未知地图', async () => {
|
||||
const result = await service.validatePermission(
|
||||
'user-123',
|
||||
'Some Stream',
|
||||
'unknown_map'
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 属性测试: 内容安全和频率控制
|
||||
*
|
||||
* **Feature: zulip-integration, Property 7: 内容安全和频率控制**
|
||||
* **Validates: Requirements 4.3, 4.4**
|
||||
*
|
||||
* 对于任何包含敏感词或高频发送的消息,系统应该正确过滤敏感内容,
|
||||
* 实施频率限制,并返回适当的提示信息
|
||||
*/
|
||||
describe('Property 7: 内容安全和频率控制', () => {
|
||||
/**
|
||||
* 属性: 对于任何有效的非敏感消息,内容过滤应该允许通过
|
||||
* 验证需求 4.3: 消息内容包含敏感词时系统应过滤敏感内容或拒绝发送
|
||||
*/
|
||||
it('对于任何有效的非敏感消息,内容过滤应该允许通过', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成有效的非敏感消息(字母、数字、空格组成)
|
||||
fc.string({ minLength: 1, maxLength: 500 })
|
||||
.filter(s => s.trim().length > 0)
|
||||
.filter(s => !/(.)\1{4,}/.test(s)) // 排除重复字符
|
||||
.filter(s => !['垃圾', '广告', '刷屏', '傻逼', '操你'].some(w => s.includes(w))) // 排除敏感词
|
||||
.map(s => s.replace(/(.{2,})\1{2,}/g, '$1')), // 移除重复短语
|
||||
async (content) => {
|
||||
const result = await service.filterContent(content);
|
||||
|
||||
// 有效的非敏感消息应该被允许
|
||||
if (content.trim().length > 0 && content.length <= 1000) {
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* 属性: 对于任何包含敏感词的消息,应该被过滤或拒绝
|
||||
* 验证需求 4.3: 消息内容包含敏感词时系统应过滤敏感内容或拒绝发送
|
||||
*/
|
||||
it('对于任何包含敏感词的消息,应该被过滤或拒绝', async () => {
|
||||
const sensitiveWords = ['垃圾', '广告', '刷屏'];
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成包含敏感词的消息
|
||||
fc.constantFrom(...sensitiveWords),
|
||||
fc.string({ minLength: 0, maxLength: 50 }),
|
||||
fc.string({ minLength: 0, maxLength: 50 }),
|
||||
async (sensitiveWord, prefix, suffix) => {
|
||||
const content = `${prefix}${sensitiveWord}${suffix}`;
|
||||
const result = await service.filterContent(content);
|
||||
|
||||
// 包含敏感词的消息应该被过滤(替换为星号)或拒绝
|
||||
if (result.allowed) {
|
||||
// 如果允许,敏感词应该被替换
|
||||
expect(result.filtered).toBeDefined();
|
||||
expect(result.filtered).not.toContain(sensitiveWord);
|
||||
expect(result.filtered).toContain('*'.repeat(sensitiveWord.length));
|
||||
}
|
||||
// 如果不允许,reason应该有值
|
||||
if (!result.allowed) {
|
||||
expect(result.reason).toBeDefined();
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* 属性: 对于任何空或只包含空白字符的消息,应该被拒绝
|
||||
* 验证需求 4.3: 消息内容验证
|
||||
*/
|
||||
it('对于任何空或只包含空白字符的消息,应该被拒绝', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成空白字符串
|
||||
fc.constantFrom('', ' ', ' ', '\t', '\n', ' \t\n '),
|
||||
async (content) => {
|
||||
const result = await service.filterContent(content);
|
||||
|
||||
// 空或空白消息应该被拒绝
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBeDefined();
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* 属性: 对于任何超过长度限制的消息,应该被拒绝
|
||||
* 验证需求 4.3: 消息长度验证
|
||||
*/
|
||||
it('对于任何超过长度限制的消息,应该被拒绝', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成超长消息
|
||||
fc.integer({ min: 1001, max: 2000 }),
|
||||
async (length) => {
|
||||
const content = 'a'.repeat(length);
|
||||
const result = await service.filterContent(content);
|
||||
|
||||
// 超长消息应该被拒绝
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('过长');
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* 属性: 对于任何用户,在频率限制窗口内发送超过限制数量的消息应该被拒绝
|
||||
* 验证需求 4.4: 玩家发送频率过高时系统应实施频率限制并返回限制提示
|
||||
*/
|
||||
it('对于任何用户,在频率限制窗口内发送超过限制数量的消息应该被拒绝', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成用户ID
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
// 生成发送次数(超过限制)
|
||||
fc.integer({ min: 11, max: 20 }),
|
||||
async (userId, sendCount) => {
|
||||
// 清理之前的数据
|
||||
memoryStore.clear();
|
||||
|
||||
const results: boolean[] = [];
|
||||
|
||||
// 发送多条消息
|
||||
for (let i = 0; i < sendCount; i++) {
|
||||
const result = await service.checkRateLimit(userId.trim());
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// 前10条应该被允许
|
||||
const allowedCount = results.filter(r => r).length;
|
||||
expect(allowedCount).toBe(10);
|
||||
|
||||
// 超过10条的应该被拒绝
|
||||
const rejectedCount = results.filter(r => !r).length;
|
||||
expect(rejectedCount).toBe(sendCount - 10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* 属性: 对于任何用户,在频率限制内的消息应该被允许
|
||||
* 验证需求 4.4: 正常频率的消息应该被允许
|
||||
*/
|
||||
it('对于任何用户,在频率限制内的消息应该被允许', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成用户ID
|
||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||
// 生成发送次数(在限制内)
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
async (userId, sendCount) => {
|
||||
// 清理之前的数据
|
||||
memoryStore.clear();
|
||||
|
||||
// 发送消息
|
||||
for (let i = 0; i < sendCount; i++) {
|
||||
const result = await service.checkRateLimit(userId.trim());
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* 属性: 对于任何包含过多重复字符的消息,应该被拒绝
|
||||
* 验证需求 4.3: 防刷屏检测
|
||||
*/
|
||||
it('对于任何包含过多重复字符的消息,应该被拒绝', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成单个字符
|
||||
fc.constantFrom('a', 'b', 'c', 'd', 'e', '1', '2', '3'),
|
||||
// 生成重复次数(超过5次)
|
||||
fc.integer({ min: 5, max: 20 }),
|
||||
async (char: string, repeatCount: number) => {
|
||||
const content = char.repeat(repeatCount);
|
||||
const result = await service.filterContent(content);
|
||||
|
||||
// 包含过多重复字符的消息应该被拒绝
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('重复');
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* 属性: 综合验证 - 对于任何消息,过滤结果应该是确定性的
|
||||
* 验证需求 4.3, 4.4: 过滤行为的一致性
|
||||
*/
|
||||
it('对于任何消息,过滤结果应该是确定性的', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
// 生成任意消息
|
||||
fc.string({ minLength: 0, maxLength: 500 }),
|
||||
async (content) => {
|
||||
// 对同一消息进行两次过滤
|
||||
const result1 = await service.filterContent(content);
|
||||
const result2 = await service.filterContent(content);
|
||||
|
||||
// 结果应该一致
|
||||
expect(result1.allowed).toBe(result2.allowed);
|
||||
expect(result1.reason).toBe(result2.reason);
|
||||
expect(result1.filtered).toBe(result2.filtered);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe('validateMessage - 综合消息验证', () => {
|
||||
it('应该对有效消息返回允许', async () => {
|
||||
const result = await service.validateMessage(
|
||||
'user-123',
|
||||
'Hello, world!',
|
||||
'Novice Village',
|
||||
'novice_village'
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对无效内容返回拒绝', async () => {
|
||||
const result = await service.validateMessage(
|
||||
'user-123',
|
||||
'',
|
||||
'Novice Village',
|
||||
'novice_village'
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对位置不匹配返回拒绝', async () => {
|
||||
const result = await service.validateMessage(
|
||||
'user-123',
|
||||
'Hello',
|
||||
'Tavern',
|
||||
'novice_village'
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logViolation - 违规记录', () => {
|
||||
it('应该成功记录违规行为', async () => {
|
||||
await service.logViolation('user-123', ViolationType.CONTENT, {
|
||||
reason: 'test violation',
|
||||
});
|
||||
|
||||
// 验证Redis被调用
|
||||
expect(mockRedisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetUserRateLimit - 重置频率限制', () => {
|
||||
it('应该成功重置用户频率限制', async () => {
|
||||
// 先发送一些消息
|
||||
await service.checkRateLimit('user-123');
|
||||
await service.checkRateLimit('user-123');
|
||||
|
||||
// 重置
|
||||
await service.resetUserRateLimit('user-123');
|
||||
|
||||
// 验证Redis del被调用
|
||||
expect(mockRedisService.del).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('敏感词管理', () => {
|
||||
it('应该能够添加敏感词', () => {
|
||||
const initialCount = service.getSensitiveWords().length;
|
||||
service.addSensitiveWord('测试词', 'replace', 'test');
|
||||
expect(service.getSensitiveWords().length).toBe(initialCount + 1);
|
||||
});
|
||||
|
||||
it('应该能够移除敏感词', () => {
|
||||
service.addSensitiveWord('临时词', 'replace');
|
||||
const result = service.removeSensitiveWord('临时词');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该返回过滤服务统计信息', () => {
|
||||
const stats = service.getFilterStats();
|
||||
expect(stats.sensitiveWordsCount).toBeGreaterThan(0);
|
||||
expect(stats.rateLimit).toBe(10);
|
||||
expect(stats.maxMessageLength).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user