Compare commits
27 Commits
30a4a2813d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc1566e616 | |||
|
|
7cac8ad8a5 | ||
|
|
cf1b37af78 | ||
|
|
1849415b11 | ||
|
|
963e6ca90f | ||
|
|
cd2a197288 | ||
| 01787d701c | |||
| 6e7de1a11a | |||
|
|
d92a078fc7 | ||
| 9785908ca9 | |||
| 592a745b8f | |||
|
|
cde20c6fd7 | ||
|
|
a8de2564b6 | ||
|
|
9f4d291619 | ||
|
|
4f18f0fec6 | ||
|
|
519394645a | ||
|
|
223ba2abb8 | ||
|
|
e54d5e3939 | ||
| 299627dac7 | |||
| ae3a256c52 | |||
|
|
434766beb5 | ||
| 97ea698f38 | |||
|
|
8132300e38 | ||
|
|
4265943375 | ||
|
|
7eceb6d6d6 | ||
|
|
662694ba9f | ||
|
|
ed04b8c92d |
@@ -1,9 +1,17 @@
|
|||||||
# AI Code Inspection Guide - Whale Town Game Server
|
# AI Code Inspection Guide - Whale Town Game Server
|
||||||
|
|
||||||
## 🎯 Pre-execution Setup
|
## ⚠️ 🚨 CRITICAL: MANDATORY PRE-EXECUTION REQUIREMENTS 🚨 ⚠️
|
||||||
|
|
||||||
### 🚀 User Information Setup
|
**<EFBFBD> AI MUST READ THIS SECTION FIRST - EXECUTION WITHOUT COMPLETING THESE STEPS IS STRICTLY FORBIDDEN 🔴**
|
||||||
**Before starting any inspection steps, run the user information script:**
|
|
||||||
|
**⛔ STOP! Before executing ANY step (including Step 1), you MUST complete ALL items in this section! ⛔**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Pre-execution Setup (MANDATORY - CANNOT BE SKIPPED)
|
||||||
|
|
||||||
|
### 🚀 User Information Setup (STEP 0 - MUST EXECUTE FIRST)
|
||||||
|
**⚠️ CRITICAL: Before starting any inspection steps (including Step 1), MUST run the user information script:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enter AI-reading directory
|
# Enter AI-reading directory
|
||||||
@@ -13,6 +21,13 @@ cd docs/ai-reading
|
|||||||
node tools/setup-user-info.js
|
node tools/setup-user-info.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**🚨 VERIFICATION CHECKPOINT:**
|
||||||
|
- [ ] Have you executed `node tools/setup-user-info.js`?
|
||||||
|
- [ ] Does `docs/ai-reading/me.config.json` file exist?
|
||||||
|
- [ ] Have you read and confirmed the user's date and name from the config file?
|
||||||
|
|
||||||
|
**⛔ IF ANY CHECKBOX ABOVE IS UNCHECKED, YOU CANNOT PROCEED TO STEP 1! ⛔**
|
||||||
|
|
||||||
#### Script Functions
|
#### Script Functions
|
||||||
- Automatically get current date (YYYY-MM-DD format)
|
- Automatically get current date (YYYY-MM-DD format)
|
||||||
- Check if config file exists or date matches
|
- Check if config file exists or date matches
|
||||||
@@ -43,13 +58,49 @@ const userName = config.name; // e.g.: "John"
|
|||||||
const modifyRecord = `- ${userDate}: Code standard optimization - Clean unused imports (Modified by: ${userName})`;
|
const modifyRecord = `- ${userDate}: Code standard optimization - Clean unused imports (Modified by: ${userName})`;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🏗️ Project Characteristics
|
### 🏗️ Project Characteristics (MUST UNDERSTAND BEFORE STEP 1)
|
||||||
|
**⚠️ AI MUST read and understand these characteristics BEFORE executing Step 1:**
|
||||||
|
|
||||||
This project is a **NestJS Game Server** with the following features:
|
This project is a **NestJS Game Server** with the following features:
|
||||||
- **Dual-mode Architecture**: Supports both database and memory modes
|
- **Dual-mode Architecture**: Supports both database and memory modes
|
||||||
- **Real-time Communication**: WebSocket-based real-time bidirectional communication
|
- **Real-time Communication**: WebSocket-based real-time bidirectional communication
|
||||||
- **Property Testing**: Admin modules use fast-check for randomized testing
|
- **Property Testing**: Admin modules use fast-check for randomized testing
|
||||||
- **Layered Architecture**: Core layer (technical implementation) + Business layer (business logic)
|
- **Layered Architecture**: Core layer (technical implementation) + Business layer (business logic)
|
||||||
|
|
||||||
|
**🚨 VERIFICATION CHECKPOINT:**
|
||||||
|
- [ ] Have you read and understood the project is a NestJS Game Server?
|
||||||
|
- [ ] Have you understood the dual-mode architecture?
|
||||||
|
- [ ] Have you understood the WebSocket real-time communication feature?
|
||||||
|
- [ ] Have you understood the property testing requirements?
|
||||||
|
- [ ] Have you understood the layered architecture?
|
||||||
|
|
||||||
|
**⛔ IF ANY CHECKBOX ABOVE IS UNCHECKED, YOU CANNOT PROCEED TO STEP 1! ⛔**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 FINAL PRE-EXECUTION CHECKLIST (MUST COMPLETE BEFORE STEP 1) 🚨
|
||||||
|
|
||||||
|
**Before proceeding to Step 1, AI MUST confirm ALL of the following:**
|
||||||
|
|
||||||
|
### ✅ Mandatory Completion Checklist:
|
||||||
|
- [ ] ✅ Executed `node tools/setup-user-info.js` script
|
||||||
|
- [ ] ✅ Confirmed `me.config.json` file exists and contains valid date and name
|
||||||
|
- [ ] ✅ Read and stored user's date from config file
|
||||||
|
- [ ] ✅ Read and stored user's name from config file
|
||||||
|
- [ ] ✅ Understood this is a NestJS Game Server project
|
||||||
|
- [ ] ✅ Understood the dual-mode architecture (database + memory)
|
||||||
|
- [ ] ✅ Understood the WebSocket real-time communication feature
|
||||||
|
- [ ] ✅ Understood the property testing requirements
|
||||||
|
- [ ] ✅ Understood the layered architecture (Core + Business)
|
||||||
|
- [ ] ✅ Read and understood the execution principles below
|
||||||
|
|
||||||
|
### 🔴 CRITICAL RULE:
|
||||||
|
**IF ANY ITEM IN THE CHECKLIST ABOVE IS NOT COMPLETED, AI IS ABSOLUTELY FORBIDDEN FROM EXECUTING STEP 1 OR ANY OTHER STEPS!**
|
||||||
|
|
||||||
|
**AI MUST explicitly confirm completion of ALL checklist items before proceeding to Step 1!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔄 Execution Principles
|
## 🔄 Execution Principles
|
||||||
|
|
||||||
### 🚨 Mid-step Start Requirements (Important)
|
### 🚨 Mid-step Start Requirements (Important)
|
||||||
@@ -95,9 +146,19 @@ Start Executing Specified Step
|
|||||||
```
|
```
|
||||||
User Requests Code Inspection
|
User Requests Code Inspection
|
||||||
↓
|
↓
|
||||||
Collect User Info (date, name)
|
🚨 MANDATORY: Execute node tools/setup-user-info.js
|
||||||
↓
|
↓
|
||||||
Identify Project Characteristics
|
🚨 MANDATORY: Verify me.config.json exists
|
||||||
|
↓
|
||||||
|
🚨 MANDATORY: Read and Store User Info (date, name)
|
||||||
|
↓
|
||||||
|
🚨 MANDATORY: Understand Project Characteristics
|
||||||
|
↓
|
||||||
|
🚨 MANDATORY: Complete Pre-execution Checklist
|
||||||
|
↓
|
||||||
|
🚨 MANDATORY: Explicitly Confirm ALL Checklist Items Completed
|
||||||
|
↓
|
||||||
|
⛔ CHECKPOINT: Cannot proceed without completing above steps ⛔
|
||||||
↓
|
↓
|
||||||
Execute Step 1 → Provide Report → Wait for Confirmation
|
Execute Step 1 → Provide Report → Wait for Confirmation
|
||||||
↓
|
↓
|
||||||
@@ -132,7 +193,18 @@ Execute Step 7 → Provide Report → Wait for Confirmation
|
|||||||
|
|
||||||
## 📚 Step Execution Guide
|
## 📚 Step Execution Guide
|
||||||
|
|
||||||
|
**🚨 REMINDER: Before executing Step 1, ensure you have completed ALL items in the "FINAL PRE-EXECUTION CHECKLIST" above! 🚨**
|
||||||
|
|
||||||
### Step 1: Naming Convention Check
|
### Step 1: Naming Convention Check
|
||||||
|
**⚠️ BEFORE STARTING STEP 1, AI MUST:**
|
||||||
|
1. ✅ Confirm `node tools/setup-user-info.js` has been executed
|
||||||
|
2. ✅ Confirm user date and name have been read from `me.config.json`
|
||||||
|
3. ✅ Confirm project characteristics have been understood
|
||||||
|
4. ✅ Explicitly state: "Pre-execution checklist completed, now starting Step 1"
|
||||||
|
|
||||||
|
**ONLY AFTER EXPLICIT CONFIRMATION CAN STEP 1 BEGIN!**
|
||||||
|
|
||||||
|
---
|
||||||
**Read when executing:** `step1-naming-convention.md`
|
**Read when executing:** `step1-naming-convention.md`
|
||||||
**Focus on:** Folder structure flattening, game server special file types
|
**Focus on:** Folder structure flattening, game server special file types
|
||||||
**After completion:** Provide inspection report, wait for user confirmation
|
**After completion:** Provide inspection report, wait for user confirmation
|
||||||
@@ -203,8 +275,9 @@ Execute Step 7 → Provide Report → Wait for Confirmation
|
|||||||
|
|
||||||
### Step 7: Code Commit
|
### Step 7: Code Commit
|
||||||
**Read when executing:** `step7-code-commit.md`
|
**Read when executing:** `step7-code-commit.md`
|
||||||
**Focus on:** Git change verification, modification record consistency check, standardized commit process
|
**Focus on:** Git change detection, branch management, commit message standards, merge document generation
|
||||||
**After completion:** Provide inspection report, wait for user confirmation
|
**🚨 Important Principle:** Regardless of when or what type of changes were made, if Git detects changes, provide commit service
|
||||||
|
**After completion:** Provide commit report, generate merge document
|
||||||
|
|
||||||
## 📋 Unified Report Template
|
## 📋 Unified Report Template
|
||||||
|
|
||||||
@@ -394,4 +467,56 @@ When issues are discovered and modifications made in any step, must follow this
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Before starting execution, please first run `node tools/setup-user-info.js` to set user information!**
|
## 🔴 FINAL WARNING 🔴
|
||||||
|
|
||||||
|
**⚠️ AI EXECUTION REQUIREMENTS SUMMARY:**
|
||||||
|
|
||||||
|
1. **STEP 0 (MANDATORY)**: Execute `node tools/setup-user-info.js`
|
||||||
|
2. **STEP 0.1 (MANDATORY)**: Verify `me.config.json` exists and read user info
|
||||||
|
3. **STEP 0.2 (MANDATORY)**: Understand project characteristics
|
||||||
|
4. **STEP 0.3 (MANDATORY)**: Complete and explicitly confirm pre-execution checklist
|
||||||
|
5. **ONLY THEN**: Begin Step 1
|
||||||
|
|
||||||
|
**🚨 VIOLATION CONSEQUENCES:**
|
||||||
|
- If AI skips Step 0 and directly executes Step 1, user MUST stop AI and require restart
|
||||||
|
- If AI does not explicitly confirm checklist completion, user MUST stop AI and require confirmation
|
||||||
|
- If AI does not read user info from config file, all subsequent steps are INVALID
|
||||||
|
|
||||||
|
**✅ CORRECT EXECUTION START:**
|
||||||
|
```
|
||||||
|
AI: "I will now begin the code inspection process.
|
||||||
|
|
||||||
|
Step 0 - Pre-execution Setup:
|
||||||
|
1. ✅ Executing user information setup script...
|
||||||
|
Command: cd docs/ai-reading && node tools/setup-user-info.js
|
||||||
|
|
||||||
|
2. ✅ Verifying me.config.json exists...
|
||||||
|
File found: docs/ai-reading/me.config.json
|
||||||
|
|
||||||
|
3. ✅ Reading user information...
|
||||||
|
User Date: 2026-01-19
|
||||||
|
User Name: [Name from config]
|
||||||
|
|
||||||
|
4. ✅ Understanding project characteristics...
|
||||||
|
- NestJS Game Server ✓
|
||||||
|
- Dual-mode Architecture ✓
|
||||||
|
- WebSocket Communication ✓
|
||||||
|
- Property Testing ✓
|
||||||
|
- Layered Architecture ✓
|
||||||
|
|
||||||
|
5. ✅ Pre-execution checklist completed!
|
||||||
|
|
||||||
|
All mandatory pre-execution requirements have been satisfied.
|
||||||
|
Now proceeding to Step 1: Naming Convention Check..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**⛔ INCORRECT EXECUTION START (FORBIDDEN):**
|
||||||
|
```
|
||||||
|
AI: "I will start with Step 1: Naming Convention Check..." ❌ WRONG!
|
||||||
|
AI: "Let me check the naming conventions..." ❌ WRONG!
|
||||||
|
AI: "Starting code inspection..." ❌ WRONG!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎯 Remember: Step 0 is NOT optional - it is MANDATORY before ANY other step!**
|
||||||
@@ -151,8 +151,9 @@
|
|||||||
5. **更新引用路径**:修改所有import语句
|
5. **更新引用路径**:修改所有import语句
|
||||||
|
|
||||||
### 扁平化标准
|
### 扁平化标准
|
||||||
- **≤3个文件**:必须扁平化处理
|
- **1个文件**:必须扁平化处理
|
||||||
- **≥4个文件**:通常保持独立文件夹
|
- **2个文件**:建议扁平化处理(除非是完整功能模块)
|
||||||
|
- **≥3个文件**:保持独立文件夹
|
||||||
- **完整功能模块**:即使文件较少也可保持独立(需特殊说明)
|
- **完整功能模块**:即使文件较少也可保持独立(需特殊说明)
|
||||||
|
|
||||||
### 测试文件位置规范(重要)
|
### 测试文件位置规范(重要)
|
||||||
@@ -211,7 +212,7 @@ src/business/auth/
|
|||||||
|
|
||||||
1. **只看文件夹名称,不检查内容**
|
1. **只看文件夹名称,不检查内容**
|
||||||
2. **凭印象判断,不使用工具获取准确数据**
|
2. **凭印象判断,不使用工具获取准确数据**
|
||||||
3. **遗漏≤3个文件文件夹的识别**
|
3. **遗漏单文件或双文件文件夹的识别**
|
||||||
4. **忽略测试文件夹扁平化**:认为tests文件夹是"标准结构"
|
4. **忽略测试文件夹扁平化**:认为tests文件夹是"标准结构"
|
||||||
5. **🚨 错误地要求修改 NestJS 框架文件命名**:
|
5. **🚨 错误地要求修改 NestJS 框架文件命名**:
|
||||||
- ❌ 错误:要求将 `login.controller.ts` 改为 `login_controller.ts`(类型标识符不能用下划线)
|
- ❌ 错误:要求将 `login.controller.ts` 改为 `login_controller.ts`(类型标识符不能用下划线)
|
||||||
@@ -227,7 +228,7 @@ src/business/auth/
|
|||||||
1. **使用listDirectory工具检查目标文件夹结构**
|
1. **使用listDirectory工具检查目标文件夹结构**
|
||||||
2. **逐个检查文件和文件夹命名是否符合规范**
|
2. **逐个检查文件和文件夹命名是否符合规范**
|
||||||
3. **统计每个文件夹的文件数量**
|
3. **统计每个文件夹的文件数量**
|
||||||
4. **识别需要扁平化的文件夹(≤3个文件)**
|
4. **识别需要扁平化的文件夹(1-2个文件)**
|
||||||
5. **检查Core层模块命名是否正确**
|
5. **检查Core层模块命名是否正确**
|
||||||
6. **执行必要的文件移动和重命名操作**
|
6. **执行必要的文件移动和重命名操作**
|
||||||
7. **更新所有相关的import路径引用**
|
7. **更新所有相关的import路径引用**
|
||||||
|
|||||||
@@ -177,6 +177,227 @@ private validateUserData(userData: CreateUserDto | UpdateUserDto): void {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🚨 异常处理完整性检查(关键规范)
|
||||||
|
|
||||||
|
### 问题定义
|
||||||
|
**异常吞没(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项处理(强制要求)
|
||||||
|
|
||||||
### 处理原则
|
### 处理原则
|
||||||
@@ -323,12 +544,19 @@ describe('AdminService Properties', () => {
|
|||||||
- 抽象为可复用的工具方法
|
- 抽象为可复用的工具方法
|
||||||
- 消除代码重复
|
- 消除代码重复
|
||||||
|
|
||||||
6. **处理所有TODO项**
|
6. **🚨 检查异常处理完整性(关键步骤)**
|
||||||
|
- 扫描所有 catch 块
|
||||||
|
- 检查是否有 throw 语句
|
||||||
|
- 验证 Service/Repository 层是否传播异常
|
||||||
|
- 确认方法返回类型与实际返回一致
|
||||||
|
- 识别异常吞没模式并修复
|
||||||
|
|
||||||
|
7. **处理所有TODO项**
|
||||||
- 搜索所有TODO注释
|
- 搜索所有TODO注释
|
||||||
- 要求真正实现功能或删除代码
|
- 要求真正实现功能或删除代码
|
||||||
- 确保最终文件无TODO项
|
- 确保最终文件无TODO项
|
||||||
|
|
||||||
7. **游戏服务器特殊检查**
|
8. **游戏服务器特殊检查**
|
||||||
- WebSocket连接管理完整性
|
- WebSocket连接管理完整性
|
||||||
- 双模式服务行为一致性
|
- 双模式服务行为一致性
|
||||||
- 属性测试实现质量
|
- 属性测试实现质量
|
||||||
|
|||||||
@@ -672,17 +672,172 @@ export class AuthModule {}
|
|||||||
- Core层是否不依赖业务层
|
- Core层是否不依赖业务层
|
||||||
- 依赖注入是否正确使用
|
- 依赖注入是否正确使用
|
||||||
|
|
||||||
7. **检查架构违规**
|
8. **检查架构违规**
|
||||||
- 识别常见的分层违规模式
|
- 识别常见的分层违规模式
|
||||||
- 检查技术实现和业务逻辑的边界
|
- 检查技术实现和业务逻辑的边界
|
||||||
- 检查协议处理和业务逻辑的边界
|
- 检查协议处理和业务逻辑的边界
|
||||||
- 确保架构清晰度
|
- 确保架构清晰度
|
||||||
|
|
||||||
8. **游戏服务器特殊检查**
|
9. **游戏服务器特殊检查**
|
||||||
- WebSocket Gateway的分层正确性
|
- 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的完整检查!**
|
||||||
@@ -697,4 +852,9 @@ export class AuthModule {}
|
|||||||
- ❌ **禁止递增版本号**:不要修改@version字段
|
- ❌ **禁止递增版本号**:不要修改@version字段
|
||||||
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
- ✅ **仅提供检查报告**:说明检查结果,确认符合规范
|
||||||
|
|
||||||
**不能跳过重新检查环节!**
|
**🚀 步骤4完成的强制条件:**
|
||||||
|
1. **架构分层检查通过**:Gateway/Business/Core层职责清晰
|
||||||
|
2. **依赖注入检查通过**:所有Module的imports/exports配置正确
|
||||||
|
3. **🔥 应用启动验证通过**:执行 `pnpm dev` 应用能成功启动,无依赖错误
|
||||||
|
|
||||||
|
**不能跳过应用启动验证环节!如果启动失败,必须修复后重新执行整个步骤4!**
|
||||||
@@ -19,10 +19,36 @@
|
|||||||
## 🎯 检查目标
|
## 🎯 检查目标
|
||||||
完成代码修改后的规范化提交流程,确保代码变更记录清晰、分支管理规范、提交信息符合项目标准。
|
完成代码修改后的规范化提交流程,确保代码变更记录清晰、分支管理规范、提交信息符合项目标准。
|
||||||
|
|
||||||
|
## 🚨 重要原则:提交所有变更
|
||||||
|
|
||||||
|
### 核心原则
|
||||||
|
**无论变更是何时产生的、是什么类型的,只要 Git 检测到有变更,就应该帮助用户提交!**
|
||||||
|
|
||||||
|
### 常见误区
|
||||||
|
❌ **错误想法**:"这些变更不是本次代码检查产生的,所以不需要提交"
|
||||||
|
✅ **正确做法**:检查所有 Git 变更,分析变更类型,询问用户要提交哪些文件,然后用合适的方式提交
|
||||||
|
|
||||||
|
### 执行流程
|
||||||
|
1. **检查 Git 状态**:`git status` 查看所有变更文件
|
||||||
|
2. **分析变更内容**:`git diff` 查看每个文件的具体变更
|
||||||
|
3. **分类变更类型**:判断是功能新增、Bug修复、代码优化等
|
||||||
|
4. **询问用户意图**:确认要提交哪些文件、提交到哪个仓库
|
||||||
|
5. **选择提交策略**:根据变更类型选择合适的分支命名和提交信息
|
||||||
|
6. **执行提交操作**:创建分支、暂存文件、提交、推送
|
||||||
|
|
||||||
|
### 变更来源不重要
|
||||||
|
变更可能来自:
|
||||||
|
- 本次代码检查的修改 ✓
|
||||||
|
- 之前的功能开发 ✓
|
||||||
|
- 其他时间的代码调整 ✓
|
||||||
|
- 任何其他修改 ✓
|
||||||
|
|
||||||
|
**关键是:只要有变更,就应该提供提交服务!**
|
||||||
|
|
||||||
## 📋 执行前置条件
|
## 📋 执行前置条件
|
||||||
- 已完成前6个步骤的代码检查和修改
|
- Git 工作区有变更文件(通过 `git status` 检测)
|
||||||
- 所有修改的文件已更新修改记录和版本信息
|
- 代码能够正常运行且通过测试(如适用)
|
||||||
- 代码能够正常运行且通过测试
|
- 用户明确要提交这些变更
|
||||||
|
|
||||||
## 🚨 协作规范和范围控制
|
## 🚨 协作规范和范围控制
|
||||||
|
|
||||||
@@ -69,8 +95,30 @@ git diff
|
|||||||
git diff --cached
|
git diff --cached
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🚨 重要:不要预判变更来源
|
||||||
|
**AI 必须检查所有 Git 变更,不要因为变更不是"本次检查产生的"就忽略!**
|
||||||
|
|
||||||
|
#### 错误示例
|
||||||
|
```
|
||||||
|
❌ AI: "检测到 chat.gateway.ts 有变更,但这是功能新增,不是代码规范检查产生的,所以不需要提交。"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 正确示例
|
||||||
|
```
|
||||||
|
✅ AI: "检测到以下文件有变更:
|
||||||
|
1. chat.gateway.ts - 功能新增(添加地图切换功能)
|
||||||
|
2. auth/login.service.ts - 代码优化
|
||||||
|
3. chat/chat.service.ts - Bug修复
|
||||||
|
|
||||||
|
请问您要提交哪些文件?我可以帮您:
|
||||||
|
- 全部提交(可以分类提交不同类型的变更)
|
||||||
|
- 只提交部分文件
|
||||||
|
- 按模块分别提交"
|
||||||
|
```
|
||||||
|
|
||||||
### 2. 文件修改记录校验
|
### 2. 文件修改记录校验
|
||||||
**重要**:检查每个修改文件的头部信息是否与实际修改内容一致
|
**注意**:如果变更不是本次代码检查产生的,文件头部可能没有更新修改记录,这是正常的。
|
||||||
|
只需要检查变更内容,生成准确的提交信息即可。
|
||||||
|
|
||||||
#### 校验内容包括:
|
#### 校验内容包括:
|
||||||
- **修改记录**:最新的修改记录是否准确描述了本次变更
|
- **修改记录**:最新的修改记录是否准确描述了本次变更
|
||||||
@@ -505,6 +553,37 @@ mkdir -p docs/merge-requests
|
|||||||
- **监控要点**:关注 [具体的监控指标]
|
- **监控要点**:关注 [具体的监控指标]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🚨 合并文档不纳入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. 创建合并文档目录(如果不存在)
|
#### 1. 创建合并文档目录(如果不存在)
|
||||||
@@ -689,6 +768,7 @@ git remote show [远程仓库名]
|
|||||||
- **完整性**:每次提交的代码都应该能正常运行
|
- **完整性**:每次提交的代码都应该能正常运行
|
||||||
- **描述性**:提交信息要清晰描述改动内容、范围和原因
|
- **描述性**:提交信息要清晰描述改动内容、范围和原因
|
||||||
- **一致性**:文件修改记录必须与实际修改内容一致
|
- **一致性**:文件修改记录必须与实际修改内容一致
|
||||||
|
- **合并文档排除**:`docs/merge-requests/` 目录下的合并文档不纳入Git提交
|
||||||
|
|
||||||
### 质量保证
|
### 质量保证
|
||||||
- 提交前必须验证代码能正常运行
|
- 提交前必须验证代码能正常运行
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { AppController } from './app.controller';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { LoggerModule } from './core/utils/logger/logger.module';
|
import { LoggerModule } from './core/utils/logger/logger.module';
|
||||||
import { UsersModule } from './core/db/users/users.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 { LoginCoreModule } from './core/login_core/login_core.module';
|
||||||
import { AuthGatewayModule } from './gateway/auth/auth.gateway.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 { ZulipModule } from './business/zulip/zulip.module';
|
import { ZulipModule } from './business/zulip/zulip.module';
|
||||||
import { RedisModule } from './core/redis/redis.module';
|
import { RedisModule } from './core/redis/redis.module';
|
||||||
import { AdminModule } from './business/admin/admin.module';
|
import { AdminModule } from './business/admin/admin.module';
|
||||||
@@ -60,6 +63,8 @@ function isDatabaseConfigured(): boolean {
|
|||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
// 字符集配置 - 支持中文和emoji
|
||||||
|
charset: 'utf8mb4',
|
||||||
// 添加连接超时和重试配置
|
// 添加连接超时和重试配置
|
||||||
connectTimeout: 10000,
|
connectTimeout: 10000,
|
||||||
retryAttempts: 3,
|
retryAttempts: 3,
|
||||||
@@ -68,9 +73,13 @@ function isDatabaseConfigured(): boolean {
|
|||||||
] : []),
|
] : []),
|
||||||
// 根据数据库配置选择用户模块模式
|
// 根据数据库配置选择用户模块模式
|
||||||
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
|
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
|
||||||
|
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
|
||||||
|
ZulipAccountsModule.forRoot(),
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
AuthGatewayModule, // 使用网关层模块替代业务层模块
|
AuthGatewayModule, // 认证网关模块
|
||||||
ZulipModule,
|
ChatGatewayModule, // 聊天网关模块
|
||||||
|
ZulipGatewayModule, // Zulip网关模块(HTTP API接口)
|
||||||
|
ZulipModule, // Zulip业务模块(业务逻辑)
|
||||||
UserMgmtModule,
|
UserMgmtModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
SecurityCoreModule,
|
SecurityCoreModule,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
|||||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||||
import { UsersModule } from '../../core/db/users/users.module';
|
import { UsersModule } from '../../core/db/users/users.module';
|
||||||
import { UserProfilesModule } from '../../core/db/user_profiles/user_profiles.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 { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { AdminDatabaseController } from './admin_database.controller';
|
import { AdminDatabaseController } from './admin_database.controller';
|
||||||
@@ -55,8 +54,7 @@ function isDatabaseConfigured(): boolean {
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
// 根据数据库配置选择UserProfiles模块模式
|
// 根据数据库配置选择UserProfiles模块模式
|
||||||
isDatabaseConfigured() ? UserProfilesModule.forDatabase() : UserProfilesModule.forMemory(),
|
isDatabaseConfigured() ? UserProfilesModule.forDatabase() : UserProfilesModule.forMemory(),
|
||||||
// 根据数据库配置选择ZulipAccounts模块模式
|
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||||
isDatabaseConfigured() ? ZulipAccountsModule.forDatabase() : ZulipAccountsModule.forMemory(),
|
|
||||||
// 注册AdminOperationLog实体
|
// 注册AdminOperationLog实体
|
||||||
TypeOrmModule.forFeature([AdminOperationLog])
|
TypeOrmModule.forFeature([AdminOperationLog])
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import { LoginService } from './login.service';
|
|||||||
import { RegisterService } from './register.service';
|
import { RegisterService } from './register.service';
|
||||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_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';
|
import { UsersModule } from '../../core/db/users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -44,7 +43,7 @@ import { UsersModule } from '../../core/db/users/users.module';
|
|||||||
// 导入核心层模块
|
// 导入核心层模块
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
ZulipCoreModule,
|
ZulipCoreModule,
|
||||||
ZulipAccountsModule.forRoot(),
|
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -714,13 +714,7 @@ export class LoginService {
|
|||||||
apiKeyResult.apiKey!
|
apiKeyResult.apiKey!
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. 更新内存关联
|
// 注意:不在登录时建立内存关联,Zulip客户端将在WebSocket连接时创建
|
||||||
await this.zulipAccountService.linkGameAccount(
|
|
||||||
user.id.toString(),
|
|
||||||
zulipAccount.zulipUserId,
|
|
||||||
zulipAccount.zulipEmail,
|
|
||||||
apiKeyResult.apiKey!
|
|
||||||
);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
* - 测试Zulip账号集成
|
* - 测试Zulip账号集成
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 清理未使用的变量apiKeySecurityService (修改者: moyin)
|
||||||
* - 2026-01-12: 代码分离 - 从login.service.spec.ts中分离注册相关测试
|
* - 2026-01-12: 代码分离 - 从login.service.spec.ts中分离注册相关测试
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.1
|
||||||
* @since 2026-01-12
|
* @since 2026-01-12
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
@@ -25,7 +26,6 @@ describe('RegisterService', () => {
|
|||||||
let service: RegisterService;
|
let service: RegisterService;
|
||||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||||
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
let zulipAccountService: jest.Mocked<ZulipAccountService>;
|
||||||
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
|
|
||||||
|
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: BigInt(1),
|
id: BigInt(1),
|
||||||
@@ -96,7 +96,6 @@ describe('RegisterService', () => {
|
|||||||
service = module.get<RegisterService>(RegisterService);
|
service = module.get<RegisterService>(RegisterService);
|
||||||
loginCoreService = module.get(LoginCoreService);
|
loginCoreService = module.get(LoginCoreService);
|
||||||
zulipAccountService = module.get(ZulipAccountService);
|
zulipAccountService = module.get(ZulipAccountService);
|
||||||
apiKeySecurityService = module.get(ApiKeySecurityService);
|
|
||||||
|
|
||||||
// 设置默认的mock返回值
|
// 设置默认的mock返回值
|
||||||
const mockTokenPair = {
|
const mockTokenPair = {
|
||||||
|
|||||||
@@ -14,16 +14,17 @@
|
|||||||
* - 处理注册相关的邮箱验证和Zulip集成
|
* - 处理注册相关的邮箱验证和Zulip集成
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 清理未使用的导入TokenPair,增强userId非空验证 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码分离 - 从login.service.ts中分离注册相关业务逻辑
|
* - 2026-01-12: 代码分离 - 从login.service.ts中分离注册相关业务逻辑
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.1
|
||||||
* @since 2026-01-12
|
* @since 2026-01-12
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
import { LoginCoreService, RegisterRequest, TokenPair } from '../../core/login_core/login_core.service';
|
import { LoginCoreService, RegisterRequest } from '../../core/login_core/login_core.service';
|
||||||
import { Users } from '../../core/db/users/users.entity';
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||||
@@ -487,6 +488,11 @@ export class RegisterService {
|
|||||||
throw new Error(createResult.error || 'Zulip账号创建/绑定失败');
|
throw new Error(createResult.error || 'Zulip账号创建/绑定失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证必须获取到 userId(数据库字段 NOT NULL)
|
||||||
|
if (createResult.userId === undefined || createResult.userId === null) {
|
||||||
|
throw new Error('Zulip账号创建成功但未能获取用户ID,无法建立关联');
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 处理API Key
|
// 3. 处理API Key
|
||||||
let finalApiKey = createResult.apiKey;
|
let finalApiKey = createResult.apiKey;
|
||||||
|
|
||||||
@@ -520,22 +526,14 @@ export class RegisterService {
|
|||||||
// 5. 在数据库中创建关联记录
|
// 5. 在数据库中创建关联记录
|
||||||
await this.zulipAccountsService.create({
|
await this.zulipAccountsService.create({
|
||||||
gameUserId: gameUser.id.toString(),
|
gameUserId: gameUser.id.toString(),
|
||||||
zulipUserId: createResult.userId!,
|
zulipUserId: createResult.userId, // 已在上面验证不为 undefined
|
||||||
zulipEmail: createResult.email!,
|
zulipEmail: createResult.email!,
|
||||||
zulipFullName: gameUser.nickname,
|
zulipFullName: gameUser.nickname,
|
||||||
zulipApiKeyEncrypted: finalApiKey ? 'stored_in_redis' : '',
|
zulipApiKeyEncrypted: finalApiKey ? 'stored_in_redis' : '',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. 建立游戏账号与Zulip账号的内存关联(用于当前会话)
|
// 注意:不在注册时建立内存关联,Zulip客户端将在WebSocket连接时创建
|
||||||
if (finalApiKey) {
|
|
||||||
await this.zulipAccountService.linkGameAccount(
|
|
||||||
gameUser.id.toString(),
|
|
||||||
createResult.userId!,
|
|
||||||
createResult.email!,
|
|
||||||
finalApiKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
* - 接口导出验证
|
* - 接口导出验证
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.1
|
||||||
* @since 2026-01-14
|
* @since 2026-01-14
|
||||||
* @lastModified 2026-01-14
|
* @lastModified 2026-01-19
|
||||||
|
*
|
||||||
|
* 修改记录:
|
||||||
|
* - 2026-01-19 moyin: Bug修复 - 添加缺失的ZulipAccountsService Mock配置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
@@ -33,6 +36,7 @@ describe('ChatModule', () => {
|
|||||||
createUserClient: jest.fn(),
|
createUserClient: jest.fn(),
|
||||||
destroyUserClient: jest.fn(),
|
destroyUserClient: jest.fn(),
|
||||||
sendMessage: jest.fn(),
|
sendMessage: jest.fn(),
|
||||||
|
getUserClient: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockZulipConfigService = {
|
const mockZulipConfigService = {
|
||||||
@@ -61,6 +65,10 @@ describe('ChatModule', () => {
|
|||||||
verifyToken: jest.fn(),
|
verifyToken: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockZulipAccountsService = {
|
||||||
|
findByGameUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// 禁用日志输出
|
// 禁用日志输出
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||||
@@ -97,6 +105,10 @@ describe('ChatModule', () => {
|
|||||||
provide: LoginCoreService,
|
provide: LoginCoreService,
|
||||||
useValue: mockLoginCoreService,
|
useValue: mockLoginCoreService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: 'ZulipAccountsService',
|
||||||
|
useValue: mockZulipAccountsService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,19 @@
|
|||||||
* - 依赖 ZulipCoreModule(核心层)提供Zulip技术服务
|
* - 依赖 ZulipCoreModule(核心层)提供Zulip技术服务
|
||||||
* - 依赖 RedisModule(核心层)提供缓存服务
|
* - 依赖 RedisModule(核心层)提供缓存服务
|
||||||
* - 依赖 LoginCoreModule(核心层)提供Token验证
|
* - 依赖 LoginCoreModule(核心层)提供Token验证
|
||||||
|
* - 依赖 ZulipAccountsModule(核心层)提供Zulip账号数据访问
|
||||||
*
|
*
|
||||||
* 导出接口:
|
* 导出接口:
|
||||||
* - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用)
|
* - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用)
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 功能完善 - 添加ZulipAccountsModule依赖,支持登录时初始化Zulip客户端 (修改者: AI)
|
||||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin)
|
* - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin)
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.1.1
|
* @version 1.2.0
|
||||||
* @since 2026-01-14
|
* @since 2026-01-14
|
||||||
* @lastModified 2026-01-14
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@@ -33,6 +35,7 @@ import { ChatCleanupService } from './services/chat_cleanup.service';
|
|||||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||||
import { RedisModule } from '../../core/redis/redis.module';
|
import { RedisModule } from '../../core/redis/redis.module';
|
||||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||||
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -43,6 +46,8 @@ import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.inte
|
|||||||
RedisModule,
|
RedisModule,
|
||||||
// 登录核心模块
|
// 登录核心模块
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
|
// Zulip账号数据库模块
|
||||||
|
ZulipAccountsModule.forRoot(),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// 主聊天服务
|
// 主聊天服务
|
||||||
|
|||||||
@@ -8,9 +8,12 @@
|
|||||||
* - Token验证和错误处理
|
* - Token验证和错误处理
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.1
|
||||||
* @since 2026-01-14
|
* @since 2026-01-14
|
||||||
* @lastModified 2026-01-14
|
* @lastModified 2026-01-19
|
||||||
|
*
|
||||||
|
* 修改记录:
|
||||||
|
* - 2026-01-19 moyin: 修复handlePlayerLogout测试,删除不再调用的deleteApiKey断言和过时测试用例
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
@@ -51,6 +54,7 @@ describe('ChatService', () => {
|
|||||||
createUserClient: jest.fn(),
|
createUserClient: jest.fn(),
|
||||||
destroyUserClient: jest.fn(),
|
destroyUserClient: jest.fn(),
|
||||||
sendMessage: jest.fn(),
|
sendMessage: jest.fn(),
|
||||||
|
getUserClient: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockApiKeySecurityService = {
|
const mockApiKeySecurityService = {
|
||||||
@@ -62,6 +66,10 @@ describe('ChatService', () => {
|
|||||||
verifyToken: jest.fn(),
|
verifyToken: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockZulipAccountsService = {
|
||||||
|
findByGameUserId: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
mockWebSocketGateway = {
|
mockWebSocketGateway = {
|
||||||
broadcastToMap: jest.fn(),
|
broadcastToMap: jest.fn(),
|
||||||
sendToPlayer: jest.fn(),
|
sendToPlayer: jest.fn(),
|
||||||
@@ -90,6 +98,10 @@ describe('ChatService', () => {
|
|||||||
provide: LoginCoreService,
|
provide: LoginCoreService,
|
||||||
useValue: mockLoginCoreService,
|
useValue: mockLoginCoreService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: 'ZulipAccountsService',
|
||||||
|
useValue: mockZulipAccountsService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -100,6 +112,14 @@ describe('ChatService', () => {
|
|||||||
apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE');
|
apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE');
|
||||||
loginCoreService = module.get(LoginCoreService);
|
loginCoreService = module.get(LoginCoreService);
|
||||||
|
|
||||||
|
// 设置默认的mock行为
|
||||||
|
// ZulipAccountsService默认返回null(用户没有Zulip账号)
|
||||||
|
const zulipAccountsService = module.get('ZulipAccountsService');
|
||||||
|
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// ZulipClientPool的getUserClient默认返回null
|
||||||
|
zulipClientPool.getUserClient.mockResolvedValue(null);
|
||||||
|
|
||||||
// 设置WebSocket网关
|
// 设置WebSocket网关
|
||||||
service.setWebSocketGateway(mockWebSocketGateway);
|
service.setWebSocketGateway(mockWebSocketGateway);
|
||||||
|
|
||||||
@@ -220,14 +240,12 @@ describe('ChatService', () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
zulipClientPool.destroyUserClient.mockResolvedValue(undefined);
|
zulipClientPool.destroyUserClient.mockResolvedValue(undefined);
|
||||||
apiKeySecurityService.deleteApiKey.mockResolvedValue(undefined);
|
|
||||||
sessionService.destroySession.mockResolvedValue(true);
|
sessionService.destroySession.mockResolvedValue(true);
|
||||||
|
|
||||||
await service.handlePlayerLogout(socketId, 'manual');
|
await service.handlePlayerLogout(socketId, 'manual');
|
||||||
|
|
||||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||||
expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId);
|
expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId);
|
||||||
expect(apiKeySecurityService.deleteApiKey).toHaveBeenCalledWith(userId);
|
|
||||||
expect(sessionService.destroySession).toHaveBeenCalledWith(socketId);
|
expect(sessionService.destroySession).toHaveBeenCalledWith(socketId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,25 +275,6 @@ describe('ChatService', () => {
|
|||||||
|
|
||||||
expect(sessionService.destroySession).toHaveBeenCalled();
|
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', () => {
|
describe('sendChatMessage', () => {
|
||||||
|
|||||||
@@ -14,15 +14,16 @@
|
|||||||
* - ⚡ 低延迟聊天体验
|
* - ⚡ 低延迟聊天体验
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 功能完善 - WebSocket登录时自动初始化用户Zulip客户端 (修改者: AI)
|
||||||
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
|
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
|
||||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||||
* - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin)
|
* - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin)
|
||||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.4
|
* @version 1.1.0
|
||||||
* @since 2026-01-14
|
* @since 2026-01-14
|
||||||
* @lastModified 2026-01-14
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
@@ -34,6 +35,8 @@ import {
|
|||||||
IApiKeySecurityService,
|
IApiKeySecurityService,
|
||||||
} from '../../core/zulip_core/zulip_core.interfaces';
|
} from '../../core/zulip_core/zulip_core.interfaces';
|
||||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||||
|
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||||
|
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
||||||
|
|
||||||
// ========== 接口定义 ==========
|
// ========== 接口定义 ==========
|
||||||
|
|
||||||
@@ -47,6 +50,8 @@ export interface ChatMessageRequest {
|
|||||||
content: string;
|
content: string;
|
||||||
/** 消息范围:local(本地)、global(全局) */
|
/** 消息范围:local(本地)、global(全局) */
|
||||||
scope: string;
|
scope: string;
|
||||||
|
/** 目标地图ID(可选,不传则使用会话当前地图) */
|
||||||
|
mapId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,6 +184,8 @@ export class ChatService {
|
|||||||
@Inject('API_KEY_SECURITY_SERVICE')
|
@Inject('API_KEY_SECURITY_SERVICE')
|
||||||
private readonly apiKeySecurityService: IApiKeySecurityService,
|
private readonly apiKeySecurityService: IApiKeySecurityService,
|
||||||
private readonly loginCoreService: LoginCoreService,
|
private readonly loginCoreService: LoginCoreService,
|
||||||
|
@Inject('ZulipAccountsService')
|
||||||
|
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
|
||||||
) {
|
) {
|
||||||
this.logger.log('ChatService初始化完成');
|
this.logger.log('ChatService初始化完成');
|
||||||
}
|
}
|
||||||
@@ -217,7 +224,10 @@ export class ChatService {
|
|||||||
return { success: false, error: 'Token验证失败' };
|
return { success: false, error: 'Token验证失败' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 创建会话
|
// 3. 初始化用户的Zulip客户端(从数据库获取Zulip账号信息)
|
||||||
|
await this.initializeZulipClientForUser(userInfo.userId);
|
||||||
|
|
||||||
|
// 4. 创建会话
|
||||||
const sessionResult = await this.createUserSession(request.socketId, userInfo);
|
const sessionResult = await this.createUserSession(request.socketId, userInfo);
|
||||||
|
|
||||||
this.logger.log('玩家登录成功', {
|
this.logger.log('玩家登录成功', {
|
||||||
@@ -256,20 +266,13 @@ export class ChatService {
|
|||||||
|
|
||||||
const userId = session.userId;
|
const userId = session.userId;
|
||||||
|
|
||||||
// 清理Zulip客户端
|
// 清理Zulip客户端(注意:不删除Redis中的API Key,保持持久化)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
await this.zulipClientPool.destroyUserClient(userId);
|
await this.zulipClientPool.destroyUserClient(userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn('Zulip客户端清理失败', { error: (e as Error).message });
|
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 销毁会话
|
// 销毁会话
|
||||||
@@ -303,17 +306,20 @@ export class ChatService {
|
|||||||
return { success: false, error: '会话不存在,请重新登录' };
|
return { success: false, error: '会话不存在,请重新登录' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取上下文
|
// 2. 确定目标地图(优先使用请求中的mapId,否则使用会话当前地图)
|
||||||
const context = await this.sessionService.injectContext(request.socketId);
|
const targetMapId = request.mapId || session.currentMap;
|
||||||
|
|
||||||
|
// 3. 获取上下文
|
||||||
|
const context = await this.sessionService.injectContext(request.socketId, targetMapId);
|
||||||
const targetStream = context.stream;
|
const targetStream = context.stream;
|
||||||
const targetTopic = context.topic || 'General';
|
const targetTopic = context.topic || 'General';
|
||||||
|
|
||||||
// 3. 消息验证
|
// 4. 消息验证
|
||||||
const validationResult = await this.filterService.validateMessage(
|
const validationResult = await this.filterService.validateMessage(
|
||||||
session.userId,
|
session.userId,
|
||||||
request.content,
|
request.content,
|
||||||
targetStream,
|
targetStream,
|
||||||
session.currentMap,
|
targetMapId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validationResult.allowed) {
|
if (!validationResult.allowed) {
|
||||||
@@ -323,7 +329,7 @@ export class ChatService {
|
|||||||
const messageContent = validationResult.filteredContent || request.content;
|
const messageContent = validationResult.filteredContent || request.content;
|
||||||
const messageId = `game_${Date.now()}_${session.userId}`;
|
const messageId = `game_${Date.now()}_${session.userId}`;
|
||||||
|
|
||||||
// 4. 🚀 立即广播给游戏内玩家
|
// 5. 🚀 立即广播给游戏内玩家(根据scope决定广播范围)
|
||||||
const gameMessage: GameChatMessage = {
|
const gameMessage: GameChatMessage = {
|
||||||
t: 'chat_render',
|
t: 'chat_render',
|
||||||
from: session.username,
|
from: session.username,
|
||||||
@@ -331,14 +337,15 @@ export class ChatService {
|
|||||||
bubble: true,
|
bubble: true,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
messageId,
|
messageId,
|
||||||
mapId: session.currentMap,
|
mapId: targetMapId,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId)
|
// local: 只广播给目标地图的玩家; global: 广播给所有玩家(暂时也用地图广播)
|
||||||
|
this.broadcastToGamePlayers(targetMapId, gameMessage, request.socketId)
|
||||||
.catch(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message }));
|
.catch(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message }));
|
||||||
|
|
||||||
// 5. 🔄 异步同步到Zulip
|
// 6. 🔄 异步同步到Zulip
|
||||||
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
|
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
|
||||||
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
||||||
|
|
||||||
@@ -421,6 +428,121 @@ export class ChatService {
|
|||||||
|
|
||||||
// ========== 私有方法 ==========
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化用户的Zulip客户端
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* 1. 从数据库获取用户的Zulip账号信息
|
||||||
|
* 2. 检查Redis中是否已有API Key缓存
|
||||||
|
* 3. 如果Redis中没有,从数据库标记判断是否需要重新获取
|
||||||
|
* 4. 创建Zulip客户端实例
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
*/
|
||||||
|
private async initializeZulipClientForUser(userId: string): Promise<void> {
|
||||||
|
this.logger.log('开始初始化用户Zulip客户端', {
|
||||||
|
operation: 'initializeZulipClientForUser',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 从数据库获取用户的Zulip账号信息
|
||||||
|
const zulipAccount = await this.zulipAccountsService.findByGameUserId(userId);
|
||||||
|
|
||||||
|
if (!zulipAccount) {
|
||||||
|
this.logger.debug('用户没有关联的Zulip账号,跳过Zulip客户端初始化', {
|
||||||
|
operation: 'initializeZulipClientForUser',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zulipAccount.status !== 'active') {
|
||||||
|
this.logger.warn('用户Zulip账号状态异常,跳过初始化', {
|
||||||
|
operation: 'initializeZulipClientForUser',
|
||||||
|
userId,
|
||||||
|
status: zulipAccount.status,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查Redis中是否已有API Key
|
||||||
|
const existingApiKey = await this.apiKeySecurityService.getApiKey(userId);
|
||||||
|
|
||||||
|
if (existingApiKey.success && existingApiKey.apiKey) {
|
||||||
|
this.logger.log('Redis中已有API Key缓存,直接创建Zulip客户端', {
|
||||||
|
operation: 'initializeZulipClientForUser',
|
||||||
|
userId,
|
||||||
|
zulipEmail: zulipAccount.zulipEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建Zulip客户端
|
||||||
|
await this.createZulipClientWithApiKey(
|
||||||
|
userId,
|
||||||
|
zulipAccount.zulipEmail,
|
||||||
|
existingApiKey.apiKey
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Redis中没有API Key,记录警告
|
||||||
|
// 注意:由于登录时没有用户密码,无法重新生成API Key
|
||||||
|
// API Key应该在用户注册时存储到Redis,如果丢失需要用户重新绑定Zulip账号
|
||||||
|
this.logger.warn('Redis中没有用户的Zulip API Key缓存,无法创建Zulip客户端', {
|
||||||
|
operation: 'initializeZulipClientForUser',
|
||||||
|
userId,
|
||||||
|
zulipEmail: zulipAccount.zulipEmail,
|
||||||
|
hint: '用户可能需要重新绑定Zulip账号',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('初始化用户Zulip客户端失败', {
|
||||||
|
operation: 'initializeZulipClientForUser',
|
||||||
|
userId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
// 不抛出异常,允许用户继续登录(只是没有Zulip功能)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用API Key创建Zulip客户端
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param zulipEmail Zulip邮箱
|
||||||
|
* @param apiKey API Key
|
||||||
|
*/
|
||||||
|
private async createZulipClientWithApiKey(
|
||||||
|
userId: string,
|
||||||
|
zulipEmail: string,
|
||||||
|
apiKey: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const clientInstance = await this.zulipClientPool.createUserClient(userId, {
|
||||||
|
username: zulipEmail,
|
||||||
|
apiKey: apiKey,
|
||||||
|
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('Zulip客户端创建成功', {
|
||||||
|
operation: 'createZulipClientWithApiKey',
|
||||||
|
userId,
|
||||||
|
zulipEmail,
|
||||||
|
queueId: clientInstance.queueId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
this.logger.error('创建Zulip客户端失败', {
|
||||||
|
operation: 'createZulipClientWithApiKey',
|
||||||
|
userId,
|
||||||
|
zulipEmail,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async validateGameToken(token: string) {
|
private async validateGameToken(token: string) {
|
||||||
try {
|
try {
|
||||||
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
||||||
@@ -441,20 +563,18 @@ export class ChatService {
|
|||||||
|
|
||||||
private async createUserSession(socketId: string, userInfo: any) {
|
private async createUserSession(socketId: string, userInfo: any) {
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
|
// 尝试获取已创建的Zulip客户端的队列ID
|
||||||
let zulipQueueId = `queue_${sessionId}`;
|
let zulipQueueId = `queue_${sessionId}`;
|
||||||
|
try {
|
||||||
// 尝试创建Zulip客户端
|
const existingClient = await this.zulipClientPool.getUserClient(userInfo.userId);
|
||||||
if (userInfo.zulipApiKey) {
|
if (existingClient?.queueId) {
|
||||||
try {
|
zulipQueueId = existingClient.queueId;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug('获取Zulip客户端队列ID失败,使用默认值', {
|
||||||
|
error: (e as Error).message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await this.sessionService.createSession(
|
const session = await this.sessionService.createSession(
|
||||||
|
|||||||
@@ -1,318 +1,211 @@
|
|||||||
# Zulip 游戏集成业务模块
|
# Zulip 业务模块
|
||||||
|
|
||||||
Zulip 是游戏与Zulip社群平台的集成业务模块,提供完整的实时聊天、会话管理、消息过滤和WebSocket通信功能,实现游戏内聊天与Zulip社群的双向同步,支持基于位置的聊天上下文管理和业务规则驱动的消息过滤控制。
|
Zulip业务模块是游戏服务器与Zulip聊天系统集成的核心业务层,负责处理Zulip账号关联管理和事件处理的业务逻辑,实现游戏内聊天消息与Zulip平台的双向同步。
|
||||||
|
|
||||||
## 玩家登录和会话管理
|
## 对外提供的接口
|
||||||
|
|
||||||
### handlePlayerLogin()
|
### ZulipAccountsBusinessService
|
||||||
验证游戏Token,创建Zulip客户端,建立会话映射关系,支持JWT认证和API Key获取。
|
|
||||||
|
|
||||||
### handlePlayerLogout()
|
#### create(createDto: CreateZulipAccountDto): Promise<ZulipAccountResponseDto>
|
||||||
清理玩家会话,注销Zulip事件队列,释放相关资源,确保连接正常断开。
|
创建游戏用户与Zulip账号的关联关系,支持数据验证和唯一性检查。
|
||||||
|
|
||||||
### getSession()
|
#### findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise<ZulipAccountResponseDto | null>
|
||||||
根据socketId获取会话信息,并更新最后活动时间,支持会话状态查询。
|
根据游戏用户ID查找对应的Zulip账号关联信息,支持缓存优化。
|
||||||
|
|
||||||
### getSocketsInMap()
|
#### getStatusStatistics(): Promise<ZulipAccountStatsResponseDto>
|
||||||
获取指定地图中所有在线玩家的Socket ID列表,用于消息分发和空间过滤。
|
获取所有Zulip账号关联的状态统计信息,包括活跃、非活跃、暂停、错误状态的数量。
|
||||||
|
|
||||||
## 消息发送和处理
|
### ZulipEventProcessorService
|
||||||
|
|
||||||
### sendChatMessage()
|
#### startEventProcessing(): Promise<void>
|
||||||
处理游戏客户端发送的聊天消息,转发到对应的Zulip Stream/Topic,包含内容过滤和权限验证。
|
启动Zulip事件处理循环,监听所有活跃的事件队列。
|
||||||
|
|
||||||
### processZulipMessage()
|
#### stopEventProcessing(): Promise<void>
|
||||||
处理Zulip事件队列推送的消息,转换格式后发送给相关的游戏客户端,实现双向通信。
|
停止事件处理循环,清理所有事件队列资源。
|
||||||
|
|
||||||
### updatePlayerPosition()
|
#### registerEventQueue(queueId: string, userId: string, lastEventId?: number): Promise<void>
|
||||||
更新玩家在游戏世界中的位置信息,用于消息路由和上下文注入,支持地图切换。
|
注册新的Zulip事件队列到处理列表中。
|
||||||
|
|
||||||
## WebSocket网关功能
|
#### unregisterEventQueue(queueId: string): Promise<void>
|
||||||
|
从处理列表中注销指定的事件队列。
|
||||||
|
|
||||||
### handleConnection()
|
#### setMessageDistributor(distributor: MessageDistributor): void
|
||||||
处理游戏客户端WebSocket连接建立,记录连接信息并初始化连接状态。
|
设置消息分发器,用于向游戏客户端发送消息。
|
||||||
|
|
||||||
### handleDisconnect()
|
#### processMessageEvent(event: ZulipEvent, senderUserId: string): Promise<void>
|
||||||
处理游戏客户端连接断开,清理相关资源并执行登出逻辑。
|
处理Zulip消息事件,转换格式后分发给相关的游戏客户端。
|
||||||
|
|
||||||
### handleLogin()
|
#### convertMessageFormat(zulipMessage: ZulipMessage, streamName?: string): Promise<GameMessage>
|
||||||
处理登录消息,验证Token并建立会话,返回登录结果和用户信息。
|
将Zulip消息转换为游戏协议格式(chat_render)。
|
||||||
|
|
||||||
### handleChat()
|
#### determineTargetPlayers(message: ZulipMessage, streamName: string, senderUserId: string): Promise<string[]>
|
||||||
处理聊天消息,验证用户认证状态和消息格式,调用业务服务发送消息。
|
根据消息的Stream确定应该接收消息的玩家(空间过滤)。
|
||||||
|
|
||||||
### sendChatRender()
|
#### distributeMessage(gameMessage: GameMessage, targetPlayers: string[]): Promise<void>
|
||||||
向指定客户端发送聊天渲染消息,用于显示气泡或聊天框。
|
通过WebSocket将消息发送给目标客户端。
|
||||||
|
|
||||||
### broadcastToMap()
|
#### broadcastToMap(mapId: string, gameMessage: GameMessage): Promise<void>
|
||||||
向指定地图的所有客户端广播消息,支持区域性消息分发。
|
向指定地图区域内的所有在线玩家广播消息。
|
||||||
|
|
||||||
## 会话管理功能
|
#### getProcessingStats(): EventProcessingStats
|
||||||
|
获取事件处理的统计信息,包括活跃队列数、处理事件数等。
|
||||||
### createSession()
|
|
||||||
创建会话并绑定Socket_ID与Zulip_Queue_ID,建立WebSocket连接与Zulip队列的映射关系。
|
|
||||||
|
|
||||||
### injectContext()
|
|
||||||
上下文注入,根据玩家位置确定消息应该发送到的Zulip Stream和Topic。
|
|
||||||
|
|
||||||
### destroySession()
|
|
||||||
清理玩家会话数据,从地图玩家列表中移除,释放相关资源。
|
|
||||||
|
|
||||||
### cleanupExpiredSessions()
|
|
||||||
定时清理超时的会话数据和相关资源,返回需要注销的Zulip队列ID列表。
|
|
||||||
|
|
||||||
## 消息过滤和安全
|
|
||||||
|
|
||||||
### validateMessage()
|
|
||||||
对消息进行综合验证,包括内容过滤、频率限制和权限验证。
|
|
||||||
|
|
||||||
### filterContent()
|
|
||||||
检查消息内容是否包含敏感词,进行内容过滤和替换。
|
|
||||||
|
|
||||||
### checkRateLimit()
|
|
||||||
检查用户是否超过消息发送频率限制,防止刷屏。
|
|
||||||
|
|
||||||
### validatePermission()
|
|
||||||
验证用户是否有权限向目标Stream发送消息,防止位置欺诈。
|
|
||||||
|
|
||||||
### logViolation()
|
|
||||||
记录用户的违规行为,用于监控和分析。
|
|
||||||
|
|
||||||
## WebSocket事件接口
|
|
||||||
|
|
||||||
### 'login'
|
|
||||||
客户端登录认证,建立游戏会话并获取Zulip访问权限。
|
|
||||||
- 输入: `{ type: 'login', token: string }`
|
|
||||||
- 输出: `{ t: 'login_success', sessionId: string, userId: string, username: string, currentMap: string }` 或 `{ t: 'login_error', message: string }`
|
|
||||||
|
|
||||||
### 'logout'
|
|
||||||
客户端主动登出,清理会话资源并断开连接。
|
|
||||||
- 输入: `{ type: 'logout' }`
|
|
||||||
- 输出: `{ t: 'logout_success', message: string }`
|
|
||||||
|
|
||||||
### 'chat'
|
|
||||||
发送聊天消息,支持本地和全局范围,自动同步到Zulip。
|
|
||||||
- 输入: `{ type: 'chat', content: string, scope?: 'local'|'global' }`
|
|
||||||
- 输出: `{ t: 'chat_sent', messageId: string, message: string }` 或 `{ t: 'chat_error', message: string }`
|
|
||||||
|
|
||||||
### 'position'
|
|
||||||
更新玩家位置信息,支持地图切换和位置广播。
|
|
||||||
- 输入: `{ type: 'position', x: number, y: number, mapId: string }`
|
|
||||||
- 输出: 广播给同地图其他玩家 `{ t: 'position_update', userId: string, username: string, x: number, y: number, mapId: string }`
|
|
||||||
|
|
||||||
### 'chat_render'
|
|
||||||
接收聊天消息渲染事件,用于显示其他玩家的聊天内容。
|
|
||||||
- 输入: 无(服务器推送)
|
|
||||||
- 输出: `{ t: 'chat_render', userId: string, username: string, content: string, timestamp: number, mapId: string }`
|
|
||||||
|
|
||||||
### 'connected'
|
|
||||||
连接建立确认事件,服务器主动发送连接状态。
|
|
||||||
- 输入: 无(服务器推送)
|
|
||||||
- 输出: `{ type: 'connected', message: string, socketId: string }`
|
|
||||||
|
|
||||||
### 'error'
|
|
||||||
错误事件通知,用于处理各种异常情况和错误信息。
|
|
||||||
- 输入: 无(服务器推送)
|
|
||||||
- 输出: `{ type: 'error', message: string }`
|
|
||||||
|
|
||||||
## REST API接口
|
|
||||||
|
|
||||||
### sendMessage()
|
|
||||||
通过REST API发送聊天消息到Zulip(推荐使用WebSocket接口)。
|
|
||||||
|
|
||||||
### getChatHistory()
|
|
||||||
获取指定地图或全局的聊天历史记录,支持分页查询。
|
|
||||||
|
|
||||||
### getSystemStatus()
|
|
||||||
获取WebSocket连接状态、Zulip集成状态等系统信息。
|
|
||||||
|
|
||||||
### getWebSocketInfo()
|
|
||||||
获取WebSocket连接的详细信息,包括连接地址、协议等。
|
|
||||||
|
|
||||||
## 使用的项目内部依赖
|
## 使用的项目内部依赖
|
||||||
|
|
||||||
### ZulipCoreModule (来自 core/zulip_core)
|
### ISessionQueryService (来自 core/session_core)
|
||||||
提供Zulip核心技术服务,包括客户端池管理、配置管理和事件处理等底层技术实现。
|
会话查询接口,用于获取地图中的在线玩家和会话信息,实现空间过滤功能。
|
||||||
|
|
||||||
### LoginCoreModule (来自 core/login_core)
|
### IZulipConfigService (来自 core/zulip_core)
|
||||||
提供用户认证和Token验证服务,支持JWT令牌验证和用户信息获取。
|
Zulip配置服务接口,用于获取Stream与地图的映射关系。
|
||||||
|
|
||||||
### RedisModule (来自 core/redis)
|
### IZulipClientPoolService (来自 core/zulip_core)
|
||||||
提供会话状态缓存和数据存储服务,支持会话持久化和快速查询。
|
Zulip客户端池服务接口,用于获取用户的Zulip客户端实例。
|
||||||
|
|
||||||
### LoggerModule (来自 core/utils/logger)
|
### ZulipAccountsRepository (来自 core/db/zulip_accounts)
|
||||||
提供统一的日志记录服务,支持结构化日志和性能监控。
|
Zulip账号数据仓库,提供账号关联的CRUD操作。
|
||||||
|
|
||||||
### ZulipAccountsModule (来自 core/db/zulip_accounts)
|
### AppLoggerService (来自 core/utils/logger)
|
||||||
提供Zulip账号关联管理功能,支持用户与Zulip账号的绑定关系。
|
日志服务,用于记录业务操作和系统事件。
|
||||||
|
|
||||||
### AuthModule (来自 business/auth)
|
### Cache (来自 @nestjs/cache-manager)
|
||||||
提供JWT验证和用户认证服务,支持用户身份验证和权限控制。
|
缓存管理器,用于缓存账号查询结果和统计数据,提升查询性能。
|
||||||
|
|
||||||
### IZulipClientPoolService (来自 core/zulip_core/interfaces)
|
### CreateZulipAccountDto, ZulipAccountResponseDto (来自 core/db/zulip_accounts)
|
||||||
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集成状态和系统信息。
|
|
||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
### 双向通信支持
|
### 事件队列轮询机制
|
||||||
- WebSocket实时通信:支持游戏客户端与服务器的实时双向通信
|
- 支持多用户并发事件队列管理
|
||||||
- Zulip集成同步:实现游戏内聊天与Zulip社群的双向消息同步
|
- 2秒轮询间隔,非阻塞模式获取事件
|
||||||
- 事件驱动架构:基于事件队列处理Zulip消息推送和游戏事件
|
- 自动处理队列错误和重连机制
|
||||||
|
- 支持队列的动态注册和注销
|
||||||
|
|
||||||
### 会话状态管理
|
### 消息格式转换
|
||||||
- Redis持久化存储:会话数据存储在Redis中,支持服务重启后状态恢复
|
- Zulip消息到游戏协议(chat_render)的自动转换
|
||||||
- 自动过期清理:定时清理超时会话,释放系统资源
|
- Markdown格式移除,保留纯文本内容
|
||||||
- 多地图支持:支持玩家在不同地图间切换,自动更新地图玩家列表
|
- HTML标签清理和实体解码
|
||||||
|
- 消息长度限制(200字符)和截断处理
|
||||||
|
|
||||||
### 消息过滤和安全
|
### 空间过滤机制
|
||||||
- 敏感词过滤:支持block和replace两种级别的敏感词处理
|
- 根据Zulip Stream确定对应的游戏地图
|
||||||
- 频率限制控制:防止用户发送消息过于频繁导致刷屏
|
- 从SessionManager获取地图内的在线玩家
|
||||||
- 位置权限验证:防止用户向不匹配位置的Stream发送消息
|
- 自动排除消息发送者,避免收到自己的消息
|
||||||
- 违规行为记录:记录和统计用户违规行为,支持监控和分析
|
- 支持区域广播功能
|
||||||
|
|
||||||
### 业务规则引擎
|
### 缓存优化
|
||||||
- 上下文注入机制:根据玩家位置自动确定消息的目标Stream和Topic
|
- 账号查询结果缓存(5分钟TTL)
|
||||||
- 动态配置管理:支持地图到Stream映射关系的动态配置和热重载
|
- 统计数据缓存(1分钟TTL)
|
||||||
- 权限分级控制:支持不同用户角色的权限控制和消息发送限制
|
- 自动缓存失效和更新机制
|
||||||
|
- 缓存键前缀隔离
|
||||||
|
|
||||||
|
### 性能监控
|
||||||
|
- 操作耗时记录和日志输出
|
||||||
|
- 事件处理统计(处理事件数、消息数)
|
||||||
|
- 队列状态监控(活跃队列数、总队列数)
|
||||||
|
- 最后事件时间追踪
|
||||||
|
|
||||||
## 潜在风险
|
## 潜在风险
|
||||||
|
|
||||||
### 会话数据丢失
|
### 事件队列连接风险
|
||||||
- Redis服务故障可能导致会话数据丢失,影响用户体验
|
- Zulip服务器不可用时事件队列无法获取
|
||||||
- 建议配置Redis主从复制和持久化策略
|
- 队列ID过期导致BAD_EVENT_QUEUE_ID错误
|
||||||
- 实现会话数据的定期备份和恢复机制
|
- 网络不稳定时轮询失败
|
||||||
|
- 缓解措施:自动禁用错误队列、支持队列重新激活、错误日志记录
|
||||||
|
|
||||||
### 消息同步延迟
|
### 消息分发延迟风险
|
||||||
- Zulip服务器网络延迟可能影响消息同步实时性
|
- 大量并发消息可能导致分发延迟
|
||||||
- 大量并发消息可能导致事件队列处理延迟
|
- WebSocket连接断开时消息丢失
|
||||||
- 建议监控消息处理延迟并设置合理的超时机制
|
- 目标玩家列表过大时性能下降
|
||||||
|
- 缓解措施:异步分发、连接状态检查、分批发送
|
||||||
|
|
||||||
### 频率限制绕过
|
### 缓存一致性风险
|
||||||
- 恶意用户可能通过多个账号绕过频率限制
|
- 缓存数据与数据库不一致
|
||||||
- IP级别的频率限制可能影响正常用户
|
- 缓存清理失败导致脏数据
|
||||||
- 建议结合用户行为分析和动态调整限制策略
|
- 高并发下缓存穿透
|
||||||
|
- 缓解措施:写操作后主动清理缓存、缓存失败降级查询、合理设置TTL
|
||||||
|
|
||||||
### 敏感词过滤失效
|
### 内存泄漏风险
|
||||||
- 新型敏感词和变体可能绕过现有过滤规则
|
- 事件队列未正确注销导致内存累积
|
||||||
- 过度严格的过滤可能影响正常交流
|
- 长时间运行后统计数据累积
|
||||||
- 建议定期更新敏感词库并优化过滤算法
|
- 缓解措施:模块销毁时清理资源、提供统计重置接口
|
||||||
|
|
||||||
### WebSocket连接稳定性
|
## 架构定位
|
||||||
- 网络不稳定可能导致WebSocket连接频繁断开重连
|
|
||||||
- 大量连接可能消耗过多服务器资源
|
|
||||||
- 建议实现连接池管理和自动重连机制
|
|
||||||
|
|
||||||
### 位置验证绕过
|
- **层级**: Business层(业务层)
|
||||||
- 客户端修改可能绕过位置验证机制
|
- **职责**: 业务逻辑处理、服务协调
|
||||||
- 服务端位置验证逻辑需要持续完善
|
- **依赖**: Core层的ZulipCoreModule、ZulipAccountsModule等
|
||||||
- 建议结合多种验证手段和异常行为检测
|
|
||||||
|
|
||||||
## 使用示例
|
## 文件结构
|
||||||
|
|
||||||
### WebSocket 客户端连接
|
```
|
||||||
```typescript
|
src/business/zulip/
|
||||||
// 建立WebSocket连接
|
├── services/
|
||||||
const socket = io('ws://localhost:3000/zulip');
|
│ ├── zulip_accounts_business.service.ts # Zulip账号业务服务
|
||||||
|
│ ├── zulip_accounts_business.service.spec.ts
|
||||||
// 监听连接事件
|
│ ├── zulip_event_processor.service.ts # Zulip事件处理服务
|
||||||
socket.on('connect', () => {
|
│ └── zulip_event_processor.service.spec.ts
|
||||||
console.log('Connected to Zulip WebSocket');
|
├── zulip.module.ts # 业务模块定义
|
||||||
});
|
├── zulip.module.spec.ts # 模块测试
|
||||||
|
└── README.md # 本文档
|
||||||
// 发送登录消息
|
|
||||||
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'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取聊天历史
|
```
|
||||||
const history = await fetch('/api/zulip/chat-history?mapId=whale_port&limit=50');
|
ZulipModule (Business层)
|
||||||
const messages = await history.json();
|
├─ imports: ZulipCoreModule (Core层)
|
||||||
|
├─ imports: ZulipAccountsModule (Core层)
|
||||||
// 获取系统状态
|
├─ imports: RedisModule (Core层)
|
||||||
const status = await fetch('/api/zulip/system-status');
|
├─ imports: LoggerModule (Core层)
|
||||||
const systemInfo = await status.json();
|
├─ imports: LoginCoreModule (Core层)
|
||||||
|
├─ imports: AuthModule (Business层)
|
||||||
|
├─ imports: ChatModule (Business层)
|
||||||
|
├─ providers: [ZulipEventProcessorService, ZulipAccountsBusinessService]
|
||||||
|
└─ exports: [ZulipEventProcessorService, ZulipAccountsBusinessService, DynamicConfigManagerService]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 服务集成示例
|
## 架构规范
|
||||||
```typescript
|
|
||||||
@Injectable()
|
|
||||||
export class GameChatService {
|
|
||||||
constructor(
|
|
||||||
private readonly zulipService: ZulipService,
|
|
||||||
private readonly sessionManager: SessionManagerService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handlePlayerMessage(playerId: string, message: string) {
|
### Business层职责
|
||||||
// 获取玩家会话
|
- 业务逻辑实现
|
||||||
const session = await this.sessionManager.getSession(playerId);
|
- 服务协调和编排
|
||||||
|
- 业务规则验证
|
||||||
// 发送消息到Zulip
|
- 调用Core层服务
|
||||||
const result = await this.zulipService.sendChatMessage({
|
|
||||||
gameUserId: playerId,
|
|
||||||
content: message,
|
|
||||||
scope: 'local',
|
|
||||||
mapId: session.mapId
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 版本信息
|
### Business层禁止
|
||||||
- **版本**: 1.3.0
|
- 包含HTTP协议处理(Controller应在Gateway层)
|
||||||
- **作者**: angjustinl
|
- 直接访问数据库(应通过Core层Repository)
|
||||||
- **创建时间**: 2025-12-20
|
- 包含技术实现细节
|
||||||
- **最后修改**: 2026-01-12
|
|
||||||
|
|
||||||
## 最近修改记录
|
## 迁移说明
|
||||||
- 2026-01-12: 功能新增 - 添加完整的WebSocket事件接口文档,包含所有事件的输入输出格式说明 (修改者: moyin)
|
|
||||||
- 2026-01-07: 功能修改 - 更新业务逻辑和接口描述 (修改者: angjustinl)
|
### 2026-01-14 架构优化
|
||||||
- 2025-12-20: 功能新增 - 创建Zulip游戏集成业务模块文档 (修改者: angjustinl)
|
|
||||||
|
**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,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* 聊天控制器测试
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 测试聊天消息发送功能
|
|
||||||
* - 验证消息过滤和验证逻辑
|
|
||||||
* - 测试错误处理和异常情况
|
|
||||||
* - 验证WebSocket消息广播功能
|
|
||||||
*
|
|
||||||
* 测试范围:
|
|
||||||
* - 消息发送API测试
|
|
||||||
* - 参数验证测试
|
|
||||||
* - 错误处理测试
|
|
||||||
* - 业务逻辑验证
|
|
||||||
*
|
|
||||||
* 最近修改:
|
|
||||||
* - 2026-01-12: Bug修复 - 修复测试用例中的方法名和DTO结构 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码规范优化 - 创建测试文件,确保控制器功能的测试覆盖 (修改者: moyin)
|
|
||||||
*
|
|
||||||
* @author moyin
|
|
||||||
* @version 1.0.1
|
|
||||||
* @since 2026-01-12
|
|
||||||
* @lastModified 2026-01-12
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
|
||||||
import { ChatController } from './chat.controller';
|
|
||||||
import { ZulipService } from './zulip.service';
|
|
||||||
import { MessageFilterService } from './services/message_filter.service';
|
|
||||||
import { CleanWebSocketGateway } from './clean_websocket.gateway';
|
|
||||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
|
||||||
|
|
||||||
// Mock JwtAuthGuard
|
|
||||||
const mockJwtAuthGuard = {
|
|
||||||
canActivate: jest.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ChatController', () => {
|
|
||||||
let controller: ChatController;
|
|
||||||
let zulipService: jest.Mocked<ZulipService>;
|
|
||||||
let messageFilterService: jest.Mocked<MessageFilterService>;
|
|
||||||
let websocketGateway: jest.Mocked<CleanWebSocketGateway>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const mockZulipService = {
|
|
||||||
sendChatMessage: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockMessageFilterService = {
|
|
||||||
validateMessage: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockWebSocketGateway = {
|
|
||||||
broadcastToRoom: jest.fn(),
|
|
||||||
getActiveConnections: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [ChatController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ZulipService,
|
|
||||||
useValue: mockZulipService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: MessageFilterService,
|
|
||||||
useValue: mockMessageFilterService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: CleanWebSocketGateway,
|
|
||||||
useValue: mockWebSocketGateway,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: JwtAuthGuard,
|
|
||||||
useValue: mockJwtAuthGuard,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(JwtAuthGuard)
|
|
||||||
.useValue(mockJwtAuthGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
controller = module.get<ChatController>(ChatController);
|
|
||||||
zulipService = module.get(ZulipService);
|
|
||||||
messageFilterService = module.get(MessageFilterService);
|
|
||||||
websocketGateway = module.get(CleanWebSocketGateway);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sendMessage', () => {
|
|
||||||
const validMessageDto = {
|
|
||||||
content: 'Hello, world!',
|
|
||||||
stream: 'general',
|
|
||||||
topic: 'chat',
|
|
||||||
userId: 'user123',
|
|
||||||
scope: 'local',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should reject REST API message sending and suggest WebSocket', async () => {
|
|
||||||
// Act & Assert
|
|
||||||
await expect(controller.sendMessage(validMessageDto)).rejects.toThrow(
|
|
||||||
new HttpException(
|
|
||||||
'聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:wss://whaletownend.xinghangee.icu',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log the REST API request attempt', async () => {
|
|
||||||
// Arrange
|
|
||||||
const loggerSpy = jest.spyOn(controller['logger'], 'log');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
await controller.sendMessage(validMessageDto);
|
|
||||||
} catch (error) {
|
|
||||||
// Expected to throw
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(loggerSpy).toHaveBeenCalledWith('收到REST API聊天消息发送请求', {
|
|
||||||
operation: 'sendMessage',
|
|
||||||
content: validMessageDto.content.substring(0, 50),
|
|
||||||
scope: validMessageDto.scope,
|
|
||||||
timestamp: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different message content lengths', async () => {
|
|
||||||
// Arrange
|
|
||||||
const longMessageDto = {
|
|
||||||
...validMessageDto,
|
|
||||||
content: 'a'.repeat(100), // Long message
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(controller.sendMessage(longMessageDto)).rejects.toThrow(HttpException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty message content', async () => {
|
|
||||||
// Arrange
|
|
||||||
const emptyMessageDto = { ...validMessageDto, content: '' };
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(controller.sendMessage(emptyMessageDto)).rejects.toThrow(HttpException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
|
||||||
it('should always throw HttpException for REST API requests', async () => {
|
|
||||||
// Arrange
|
|
||||||
const validMessageDto = {
|
|
||||||
content: 'Hello, world!',
|
|
||||||
stream: 'general',
|
|
||||||
topic: 'chat',
|
|
||||||
userId: 'user123',
|
|
||||||
scope: 'local',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(controller.sendMessage(validMessageDto)).rejects.toThrow(HttpException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log error when REST API is used', async () => {
|
|
||||||
// Arrange
|
|
||||||
const validMessageDto = {
|
|
||||||
content: 'Hello, world!',
|
|
||||||
stream: 'general',
|
|
||||||
topic: 'chat',
|
|
||||||
userId: 'user123',
|
|
||||||
scope: 'local',
|
|
||||||
};
|
|
||||||
|
|
||||||
const loggerSpy = jest.spyOn(controller['logger'], 'error');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
await controller.sendMessage(validMessageDto);
|
|
||||||
} catch (error) {
|
|
||||||
// Expected to throw
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(loggerSpy).toHaveBeenCalledWith('REST API消息发送失败', {
|
|
||||||
operation: 'sendMessage',
|
|
||||||
error: expect.any(String),
|
|
||||||
timestamp: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
/**
|
|
||||||
* 聊天相关的 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 '../../gateway/auth/jwt_auth.guard';
|
|
||||||
import { ZulipService } from './zulip.service';
|
|
||||||
import { CleanWebSocketGateway } from './clean_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: CleanWebSocketGateway,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送聊天消息(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 接口:wss://whaletownend.xinghangee.icu',
|
|
||||||
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 mapPlayerCounts = await this.websocketGateway.getMapPlayerCounts();
|
|
||||||
|
|
||||||
// 获取内存使用情况
|
|
||||||
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: mapPlayerCounts,
|
|
||||||
},
|
|
||||||
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: 'wss://whaletownend.xinghangee.icu/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: 'wss://whaletownend.xinghangee.icu/game',
|
|
||||||
protocol: 'native-websocket',
|
|
||||||
path: '/game',
|
|
||||||
namespace: '/',
|
|
||||||
supportedEvents: [
|
|
||||||
'login', // 用户登录
|
|
||||||
'chat', // 发送聊天消息
|
|
||||||
'position', // 位置更新
|
|
||||||
],
|
|
||||||
supportedResponses: [
|
|
||||||
'connected', // 连接确认
|
|
||||||
'login_success', // 登录成功
|
|
||||||
'login_error', // 登录失败
|
|
||||||
'chat_sent', // 消息发送成功
|
|
||||||
'chat_error', // 消息发送失败
|
|
||||||
'chat_render', // 接收到聊天消息
|
|
||||||
'error', // 通用错误
|
|
||||||
],
|
|
||||||
quickLinks: {
|
|
||||||
testPage: '/websocket-test?from=chat-api',
|
|
||||||
apiDocs: '/api-docs',
|
|
||||||
connectionInfo: '/websocket-api/connection-info'
|
|
||||||
},
|
|
||||||
authRequired: true,
|
|
||||||
tokenType: 'JWT',
|
|
||||||
tokenFormat: {
|
|
||||||
issuer: 'whale-town',
|
|
||||||
audience: 'whale-town-users',
|
|
||||||
type: 'access',
|
|
||||||
requiredFields: ['sub', 'username', 'email', 'role']
|
|
||||||
},
|
|
||||||
documentation: '/api-docs',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
/**
|
|
||||||
* 聊天相关的 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
/**
|
|
||||||
* WebSocket网关测试
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 测试WebSocket连接管理功能
|
|
||||||
* - 验证消息广播和路由逻辑
|
|
||||||
* - 测试用户认证和会话管理
|
|
||||||
* - 验证错误处理和连接清理
|
|
||||||
*
|
|
||||||
* 测试范围:
|
|
||||||
* - 连接建立和断开测试
|
|
||||||
* - 消息处理和广播测试
|
|
||||||
* - 用户认证测试
|
|
||||||
* - 错误处理测试
|
|
||||||
*
|
|
||||||
* 最近修改:
|
|
||||||
* - 2026-01-12: 测试修复 - 修正私有方法访问和接口匹配问题,适配实际网关实现 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码规范优化 - 创建测试文件,确保WebSocket网关功能的测试覆盖 (修改者: moyin)
|
|
||||||
*
|
|
||||||
* @author moyin
|
|
||||||
* @version 1.1.0
|
|
||||||
* @since 2026-01-12
|
|
||||||
* @lastModified 2026-01-12
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { WsException } from '@nestjs/websockets';
|
|
||||||
import { CleanWebSocketGateway } from './clean_websocket.gateway';
|
|
||||||
import { SessionManagerService } from './services/session_manager.service';
|
|
||||||
import { MessageFilterService } from './services/message_filter.service';
|
|
||||||
import { ZulipService } from './zulip.service';
|
|
||||||
|
|
||||||
describe('CleanWebSocketGateway', () => {
|
|
||||||
let gateway: CleanWebSocketGateway;
|
|
||||||
let sessionManagerService: jest.Mocked<SessionManagerService>;
|
|
||||||
let messageFilterService: jest.Mocked<MessageFilterService>;
|
|
||||||
let zulipService: jest.Mocked<ZulipService>;
|
|
||||||
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
emit: jest.fn(),
|
|
||||||
disconnect: jest.fn(),
|
|
||||||
handshake: {
|
|
||||||
auth: { token: 'valid-jwt-token' },
|
|
||||||
headers: { authorization: 'Bearer valid-jwt-token' },
|
|
||||||
},
|
|
||||||
data: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockServer = {
|
|
||||||
emit: jest.fn(),
|
|
||||||
to: jest.fn().mockReturnThis(),
|
|
||||||
in: jest.fn().mockReturnThis(),
|
|
||||||
sockets: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const mockSessionManagerService = {
|
|
||||||
createSession: jest.fn(),
|
|
||||||
destroySession: jest.fn(),
|
|
||||||
getSession: jest.fn(),
|
|
||||||
updateSession: jest.fn(),
|
|
||||||
validateSession: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockMessageFilterService = {
|
|
||||||
filterMessage: jest.fn(),
|
|
||||||
validateMessageContent: jest.fn(),
|
|
||||||
checkRateLimit: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockZulipService = {
|
|
||||||
handlePlayerLogin: jest.fn(),
|
|
||||||
handlePlayerLogout: jest.fn(),
|
|
||||||
sendChatMessage: jest.fn(),
|
|
||||||
setWebSocketGateway: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
CleanWebSocketGateway,
|
|
||||||
{ provide: SessionManagerService, useValue: mockSessionManagerService },
|
|
||||||
{ provide: MessageFilterService, useValue: mockMessageFilterService },
|
|
||||||
{ provide: ZulipService, useValue: mockZulipService },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
gateway = module.get<CleanWebSocketGateway>(CleanWebSocketGateway);
|
|
||||||
sessionManagerService = module.get(SessionManagerService);
|
|
||||||
messageFilterService = module.get(MessageFilterService);
|
|
||||||
zulipService = module.get(ZulipService);
|
|
||||||
|
|
||||||
// Reset all mocks
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Gateway Initialization', () => {
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(gateway).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have all required dependencies', () => {
|
|
||||||
expect(sessionManagerService).toBeDefined();
|
|
||||||
expect(messageFilterService).toBeDefined();
|
|
||||||
expect(zulipService).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handleConnection', () => {
|
|
||||||
it('should accept valid connection with JWT token', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
emit: jest.fn(),
|
|
||||||
disconnect: jest.fn(),
|
|
||||||
handshake: {
|
|
||||||
auth: { token: 'valid-jwt-token' },
|
|
||||||
headers: { authorization: 'Bearer valid-jwt-token' },
|
|
||||||
},
|
|
||||||
data: {},
|
|
||||||
readyState: 1, // WebSocket.OPEN
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the private method calls by testing the public interface
|
|
||||||
// Since handleConnection is private, we test through the message handling
|
|
||||||
const loginMessage = {
|
|
||||||
type: 'login',
|
|
||||||
token: 'valid-jwt-token'
|
|
||||||
};
|
|
||||||
|
|
||||||
zulipService.handlePlayerLogin.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
userId: 'user123',
|
|
||||||
username: 'testuser',
|
|
||||||
sessionId: 'session123',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act - Test through public interface
|
|
||||||
await gateway['handleMessage'](mockSocket as any, loginMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(zulipService.handlePlayerLogin).toHaveBeenCalledWith({
|
|
||||||
socketId: mockSocket.id,
|
|
||||||
token: 'valid-jwt-token'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject connection with invalid JWT token', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
emit: jest.fn(),
|
|
||||||
disconnect: jest.fn(),
|
|
||||||
handshake: {
|
|
||||||
auth: { token: 'invalid-token' },
|
|
||||||
headers: { authorization: 'Bearer invalid-token' },
|
|
||||||
},
|
|
||||||
data: {},
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginMessage = {
|
|
||||||
type: 'login',
|
|
||||||
token: 'invalid-token'
|
|
||||||
};
|
|
||||||
|
|
||||||
zulipService.handlePlayerLogin.mockResolvedValue({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid token',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](mockSocket as any, loginMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(zulipService.handlePlayerLogin).toHaveBeenCalledWith({
|
|
||||||
socketId: mockSocket.id,
|
|
||||||
token: 'invalid-token'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject connection without token', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
emit: jest.fn(),
|
|
||||||
disconnect: jest.fn(),
|
|
||||||
handshake: {
|
|
||||||
auth: {},
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
data: {},
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginMessage = {
|
|
||||||
type: 'login',
|
|
||||||
// No token
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act & Assert - Should send error message
|
|
||||||
await gateway['handleMessage'](mockSocket as any, loginMessage);
|
|
||||||
|
|
||||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
message: 'Token不能为空'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handleDisconnect', () => {
|
|
||||||
it('should clean up session on disconnect', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
authenticated: true,
|
|
||||||
username: 'testuser',
|
|
||||||
currentMap: 'whale_port',
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act - Test through the cleanup method since handleDisconnect is private
|
|
||||||
await gateway['cleanupClient'](mockSocket as any, 'disconnect');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(zulipService.handlePlayerLogout).toHaveBeenCalledWith(mockSocket.id, 'disconnect');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle disconnect when session does not exist', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
authenticated: false,
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['cleanupClient'](mockSocket as any, 'disconnect');
|
|
||||||
|
|
||||||
// Assert - Should not call logout for unauthenticated users
|
|
||||||
expect(zulipService.handlePlayerLogout).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle errors during session cleanup', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
authenticated: true,
|
|
||||||
username: 'testuser',
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
zulipService.handlePlayerLogout.mockRejectedValue(new Error('Cleanup failed'));
|
|
||||||
|
|
||||||
// Act & Assert - Should not throw, just log error
|
|
||||||
await expect(gateway['cleanupClient'](mockSocket as any, 'disconnect')).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('handleMessage', () => {
|
|
||||||
const validMessage = {
|
|
||||||
type: 'chat',
|
|
||||||
content: 'Hello, world!',
|
|
||||||
scope: 'local',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
authenticated: true,
|
|
||||||
userId: 'user123',
|
|
||||||
username: 'testuser',
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should process valid chat message', async () => {
|
|
||||||
// Arrange
|
|
||||||
zulipService.sendChatMessage.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
messageId: 'msg123',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](mockSocket as any, validMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(zulipService.sendChatMessage).toHaveBeenCalledWith({
|
|
||||||
socketId: mockSocket.id,
|
|
||||||
content: validMessage.content,
|
|
||||||
scope: validMessage.scope,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject message from unauthenticated user', async () => {
|
|
||||||
// Arrange
|
|
||||||
const unauthenticatedSocket = {
|
|
||||||
...mockSocket,
|
|
||||||
authenticated: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](unauthenticatedSocket as any, validMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(unauthenticatedSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
message: '请先登录'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject message with empty content', async () => {
|
|
||||||
// Arrange
|
|
||||||
const emptyMessage = {
|
|
||||||
type: 'chat',
|
|
||||||
content: '',
|
|
||||||
scope: 'local',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](mockSocket as any, emptyMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
message: '消息内容不能为空'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zulip service errors during message sending', async () => {
|
|
||||||
// Arrange
|
|
||||||
zulipService.sendChatMessage.mockResolvedValue({
|
|
||||||
success: false,
|
|
||||||
error: 'Zulip API error',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](mockSocket as any, validMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
t: 'chat_error',
|
|
||||||
message: 'Zulip API error'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('broadcastToMap', () => {
|
|
||||||
it('should broadcast message to specific map', () => {
|
|
||||||
// Arrange
|
|
||||||
const message = {
|
|
||||||
t: 'chat',
|
|
||||||
content: 'Hello room!',
|
|
||||||
from: 'user123',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
const mapId = 'whale_port';
|
|
||||||
|
|
||||||
// Act
|
|
||||||
gateway.broadcastToMap(mapId, message);
|
|
||||||
|
|
||||||
// Assert - Since we can't easily test the internal map structure,
|
|
||||||
// we just verify the method doesn't throw
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
|
||||||
it('should handle authentication errors gracefully', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginMessage = {
|
|
||||||
type: 'login',
|
|
||||||
token: 'valid-token'
|
|
||||||
};
|
|
||||||
|
|
||||||
zulipService.handlePlayerLogin.mockRejectedValue(new Error('Auth service unavailable'));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](mockSocket as any, loginMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
message: '登录处理失败'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle message processing errors', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockSocket = {
|
|
||||||
id: 'socket123',
|
|
||||||
authenticated: true,
|
|
||||||
readyState: 1,
|
|
||||||
send: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const validMessage = {
|
|
||||||
type: 'chat',
|
|
||||||
content: 'Hello, world!',
|
|
||||||
};
|
|
||||||
|
|
||||||
zulipService.sendChatMessage.mockRejectedValue(new Error('Service error'));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await gateway['handleMessage'](mockSocket as any, validMessage);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(mockSocket.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
message: '聊天处理失败'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Connection Management', () => {
|
|
||||||
it('should track active connections', () => {
|
|
||||||
// Act
|
|
||||||
const connectionCount = gateway.getConnectionCount();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(typeof connectionCount).toBe('number');
|
|
||||||
expect(connectionCount).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should track authenticated connections', () => {
|
|
||||||
// Act
|
|
||||||
const authCount = gateway.getAuthenticatedConnectionCount();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(typeof authCount).toBe('number');
|
|
||||||
expect(authCount).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get map player counts', () => {
|
|
||||||
// Act
|
|
||||||
const mapCounts = gateway.getMapPlayerCounts();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(typeof mapCounts).toBe('object');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get players in specific map', () => {
|
|
||||||
// Act
|
|
||||||
const players = gateway.getMapPlayers('whale_port');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(Array.isArray(players)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
/**
|
|
||||||
* 清洁的WebSocket网关 - 优化版本
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 使用原生WebSocket,不依赖NestJS的WebSocket装饰器
|
|
||||||
* - 支持游戏内实时聊天广播
|
|
||||||
* - 与优化后的ZulipService集成
|
|
||||||
*
|
|
||||||
* 核心优化:
|
|
||||||
* - 🚀 实时消息广播:直接广播给同区域玩家
|
|
||||||
* - 🔄 与ZulipService的异步同步集成
|
|
||||||
* - ⚡ 低延迟聊天体验
|
|
||||||
*
|
|
||||||
* 最近修改:
|
|
||||||
* - 2026-01-10: 重构优化 - 适配优化后的ZulipService,支持实时广播 (修改者: moyin)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import * as WebSocket from 'ws';
|
|
||||||
import { ZulipService } from './zulip.service';
|
|
||||||
import { SessionManagerService } from './services/session_manager.service';
|
|
||||||
|
|
||||||
interface ExtendedWebSocket extends WebSocket {
|
|
||||||
id: string;
|
|
||||||
isAlive?: boolean;
|
|
||||||
authenticated?: boolean;
|
|
||||||
userId?: string;
|
|
||||||
username?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
currentMap?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private server: WebSocket.Server;
|
|
||||||
private readonly logger = new Logger(CleanWebSocketGateway.name);
|
|
||||||
private clients = new Map<string, ExtendedWebSocket>();
|
|
||||||
private mapRooms = new Map<string, Set<string>>(); // mapId -> Set<clientId>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly zulipService: ZulipService,
|
|
||||||
private readonly sessionManager: SessionManagerService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onModuleInit() {
|
|
||||||
const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : 3001;
|
|
||||||
|
|
||||||
this.server = new WebSocket.Server({
|
|
||||||
port,
|
|
||||||
path: '/game' // 统一使用 /game 路径
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.on('connection', (ws: ExtendedWebSocket) => {
|
|
||||||
ws.id = this.generateClientId();
|
|
||||||
ws.isAlive = true;
|
|
||||||
ws.authenticated = false;
|
|
||||||
|
|
||||||
this.clients.set(ws.id, ws);
|
|
||||||
|
|
||||||
this.logger.log(`新的WebSocket连接: ${ws.id}`);
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(data.toString());
|
|
||||||
this.handleMessage(ws, message);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('解析消息失败', error);
|
|
||||||
this.sendError(ws, '消息格式错误');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', (code, reason) => {
|
|
||||||
this.logger.log(`WebSocket连接关闭: ${ws.id}`, {
|
|
||||||
code,
|
|
||||||
reason: reason?.toString(),
|
|
||||||
authenticated: ws.authenticated,
|
|
||||||
username: ws.username
|
|
||||||
});
|
|
||||||
|
|
||||||
// 根据关闭原因确定登出类型
|
|
||||||
let logoutReason: 'manual' | 'timeout' | 'disconnect' = 'disconnect';
|
|
||||||
|
|
||||||
if (code === 1000) {
|
|
||||||
logoutReason = 'manual'; // 正常关闭,通常是主动登出
|
|
||||||
} else if (code === 1001 || code === 1006) {
|
|
||||||
logoutReason = 'disconnect'; // 异常断开
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cleanupClient(ws, logoutReason);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
|
||||||
this.logger.error(`WebSocket错误: ${ws.id}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 发送连接确认
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
type: 'connected',
|
|
||||||
message: '连接成功',
|
|
||||||
socketId: ws.id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔄 设置WebSocket网关引用到ZulipService
|
|
||||||
this.zulipService.setWebSocketGateway(this);
|
|
||||||
|
|
||||||
this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy() {
|
|
||||||
if (this.server) {
|
|
||||||
this.server.close();
|
|
||||||
this.logger.log('WebSocket服务器已关闭');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMessage(ws: ExtendedWebSocket, message: any) {
|
|
||||||
this.logger.log(`收到消息: ${ws.id}`, message);
|
|
||||||
|
|
||||||
const messageType = message.type || message.t;
|
|
||||||
|
|
||||||
this.logger.log(`消息类型: ${messageType}`, { type: message.type, t: message.t });
|
|
||||||
|
|
||||||
switch (messageType) {
|
|
||||||
case 'login':
|
|
||||||
await this.handleLogin(ws, message);
|
|
||||||
break;
|
|
||||||
case 'logout':
|
|
||||||
await this.handleLogout(ws, message);
|
|
||||||
break;
|
|
||||||
case 'chat':
|
|
||||||
await this.handleChat(ws, message);
|
|
||||||
break;
|
|
||||||
case 'position':
|
|
||||||
await this.handlePositionUpdate(ws, message);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.warn(`未知消息类型: ${messageType}`, message);
|
|
||||||
this.sendError(ws, `未知消息类型: ${messageType}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleLogin(ws: ExtendedWebSocket, message: any) {
|
|
||||||
try {
|
|
||||||
if (!message.token) {
|
|
||||||
this.sendError(ws, 'Token不能为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用ZulipService进行登录
|
|
||||||
const result = await this.zulipService.handlePlayerLogin({
|
|
||||||
socketId: ws.id,
|
|
||||||
token: message.token
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
ws.authenticated = true;
|
|
||||||
ws.userId = result.userId;
|
|
||||||
ws.username = result.username;
|
|
||||||
ws.sessionId = result.sessionId;
|
|
||||||
ws.currentMap = 'whale_port'; // 默认地图
|
|
||||||
|
|
||||||
// 加入默认地图房间
|
|
||||||
this.joinMapRoom(ws.id, ws.currentMap);
|
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'login_success',
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
userId: result.userId,
|
|
||||||
username: result.username,
|
|
||||||
currentMap: ws.currentMap
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`用户登录成功: ${result.username} (${ws.id}) 进入地图: ${ws.currentMap}`);
|
|
||||||
} else {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'login_error',
|
|
||||||
message: result.error || '登录失败'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('登录处理失败', error);
|
|
||||||
this.sendError(ws, '登录处理失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理主动登出请求
|
|
||||||
*/
|
|
||||||
private async handleLogout(ws: ExtendedWebSocket, message: any) {
|
|
||||||
try {
|
|
||||||
if (!ws.authenticated) {
|
|
||||||
this.sendError(ws, '用户未登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`用户主动登出: ${ws.username} (${ws.id})`);
|
|
||||||
|
|
||||||
// 调用ZulipService处理登出,标记为主动登出
|
|
||||||
await this.zulipService.handlePlayerLogout(ws.id, 'manual');
|
|
||||||
|
|
||||||
// 清理WebSocket状态
|
|
||||||
this.cleanupClient(ws);
|
|
||||||
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'logout_success',
|
|
||||||
message: '登出成功'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 关闭WebSocket连接
|
|
||||||
ws.close(1000, '用户主动登出');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('登出处理失败', error);
|
|
||||||
this.sendError(ws, '登出处理失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleChat(ws: ExtendedWebSocket, message: any) {
|
|
||||||
try {
|
|
||||||
if (!ws.authenticated) {
|
|
||||||
this.sendError(ws, '请先登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message.content) {
|
|
||||||
this.sendError(ws, '消息内容不能为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🚀 调用优化后的ZulipService发送消息(实时广播+异步同步)
|
|
||||||
const result = await this.zulipService.sendChatMessage({
|
|
||||||
socketId: ws.id,
|
|
||||||
content: message.content,
|
|
||||||
scope: message.scope || 'local'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'chat_sent',
|
|
||||||
messageId: result.messageId,
|
|
||||||
message: '消息发送成功'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`消息发送成功: ${ws.username} -> ${message.content}`);
|
|
||||||
} else {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
t: 'chat_error',
|
|
||||||
message: result.error || '消息发送失败'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('聊天处理失败', error);
|
|
||||||
this.sendError(ws, '聊天处理失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePositionUpdate(ws: ExtendedWebSocket, message: any) {
|
|
||||||
try {
|
|
||||||
if (!ws.authenticated) {
|
|
||||||
this.sendError(ws, '请先登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 简单的位置更新处理,这里可以添加更多逻辑
|
|
||||||
this.logger.log(`位置更新: ${ws.username} -> (${message.x}, ${message.y}) 在 ${message.mapId}`);
|
|
||||||
|
|
||||||
// 如果用户切换了地图,更新房间
|
|
||||||
if (ws.currentMap !== message.mapId) {
|
|
||||||
this.leaveMapRoom(ws.id, ws.currentMap);
|
|
||||||
this.joinMapRoom(ws.id, message.mapId);
|
|
||||||
ws.currentMap = message.mapId;
|
|
||||||
|
|
||||||
this.logger.log(`用户 ${ws.username} 切换到地图: ${message.mapId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 广播位置更新给同一地图的其他用户
|
|
||||||
this.broadcastToMap(message.mapId, {
|
|
||||||
t: 'position_update',
|
|
||||||
userId: ws.userId,
|
|
||||||
username: ws.username,
|
|
||||||
x: message.x,
|
|
||||||
y: message.y,
|
|
||||||
mapId: message.mapId
|
|
||||||
}, ws.id);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('位置更新处理失败', error);
|
|
||||||
this.sendError(ws, '位置更新处理失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🚀 实现IWebSocketGateway接口方法,供ZulipService调用
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 向指定玩家发送消息
|
|
||||||
*
|
|
||||||
* @param socketId 目标Socket ID
|
|
||||||
* @param data 消息数据
|
|
||||||
*/
|
|
||||||
public sendToPlayer(socketId: string, data: any): void {
|
|
||||||
const client = this.clients.get(socketId);
|
|
||||||
if (client && client.readyState === WebSocket.OPEN) {
|
|
||||||
this.sendMessage(client, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 向指定地图广播消息
|
|
||||||
*
|
|
||||||
* @param mapId 地图ID
|
|
||||||
* @param data 消息数据
|
|
||||||
* @param excludeId 排除的Socket ID
|
|
||||||
*/
|
|
||||||
public broadcastToMap(mapId: string, data: any, excludeId?: string): void {
|
|
||||||
const room = this.mapRooms.get(mapId);
|
|
||||||
if (!room) return;
|
|
||||||
|
|
||||||
room.forEach(clientId => {
|
|
||||||
if (clientId !== excludeId) {
|
|
||||||
const client = this.clients.get(clientId);
|
|
||||||
if (client && client.authenticated && client.readyState === WebSocket.OPEN) {
|
|
||||||
this.sendMessage(client, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原有的私有方法保持不变
|
|
||||||
private sendMessage(ws: ExtendedWebSocket, data: any) {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendError(ws: ExtendedWebSocket, message: string) {
|
|
||||||
this.sendMessage(ws, {
|
|
||||||
type: 'error',
|
|
||||||
message: message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastMessage(data: any, excludeId?: string) {
|
|
||||||
this.clients.forEach((client, id) => {
|
|
||||||
if (id !== excludeId && client.authenticated) {
|
|
||||||
this.sendMessage(client, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private joinMapRoom(clientId: string, mapId: string) {
|
|
||||||
if (!this.mapRooms.has(mapId)) {
|
|
||||||
this.mapRooms.set(mapId, new Set());
|
|
||||||
}
|
|
||||||
this.mapRooms.get(mapId).add(clientId);
|
|
||||||
|
|
||||||
this.logger.log(`客户端 ${clientId} 加入地图房间: ${mapId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private leaveMapRoom(clientId: string, mapId: string) {
|
|
||||||
const room = this.mapRooms.get(mapId);
|
|
||||||
if (room) {
|
|
||||||
room.delete(clientId);
|
|
||||||
if (room.size === 0) {
|
|
||||||
this.mapRooms.delete(mapId);
|
|
||||||
}
|
|
||||||
this.logger.log(`客户端 ${clientId} 离开地图房间: ${mapId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') {
|
|
||||||
try {
|
|
||||||
// 如果用户已认证,调用ZulipService处理登出
|
|
||||||
if (ws.authenticated && ws.id) {
|
|
||||||
this.logger.log(`清理已认证用户: ${ws.username} (${ws.id})`, { reason });
|
|
||||||
await this.zulipService.handlePlayerLogout(ws.id, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从地图房间中移除
|
|
||||||
if (ws.currentMap) {
|
|
||||||
this.leaveMapRoom(ws.id, ws.currentMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从客户端列表中移除
|
|
||||||
this.clients.delete(ws.id);
|
|
||||||
|
|
||||||
this.logger.log(`客户端清理完成: ${ws.id}`, {
|
|
||||||
reason,
|
|
||||||
wasAuthenticated: ws.authenticated,
|
|
||||||
username: ws.username
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`清理客户端失败: ${ws.id}`, {
|
|
||||||
error: (error as Error).message,
|
|
||||||
reason,
|
|
||||||
username: ws.username
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateClientId(): string {
|
|
||||||
return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 公共方法供其他服务调用
|
|
||||||
public getConnectionCount(): number {
|
|
||||||
return this.clients.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAuthenticatedConnectionCount(): number {
|
|
||||||
return Array.from(this.clients.values()).filter(client => client.authenticated).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMapPlayerCounts(): Record<string, number> {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
this.mapRooms.forEach((clients, mapId) => {
|
|
||||||
counts[mapId] = clients.size;
|
|
||||||
});
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMapPlayers(mapId: string): string[] {
|
|
||||||
const room = this.mapRooms.get(mapId);
|
|
||||||
if (!room) return [];
|
|
||||||
|
|
||||||
const players: string[] = [];
|
|
||||||
room.forEach(clientId => {
|
|
||||||
const client = this.clients.get(clientId);
|
|
||||||
if (client && client.authenticated && client.username) {
|
|
||||||
players.push(client.username);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return players;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
/**
|
|
||||||
* 消息过滤服务测试
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 测试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/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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,996 +0,0 @@
|
|||||||
/**
|
|
||||||
* 消息过滤服务
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 实施内容审核和频率控制
|
|
||||||
* - 敏感词过滤和权限验证
|
|
||||||
* - 防止恶意操作和滥用
|
|
||||||
* - 与ConfigManager集成实现位置权限验证
|
|
||||||
*
|
|
||||||
* 职责分离:
|
|
||||||
* - 内容审核:检查消息内容是否包含敏感词和恶意链接
|
|
||||||
* - 频率控制:防止用户发送消息过于频繁导致刷屏
|
|
||||||
* - 权限验证:验证用户是否有权限向目标Stream发送消息
|
|
||||||
* - 违规记录:记录和统计用户的违规行为
|
|
||||||
* - 规则管理:动态管理敏感词列表和过滤规则
|
|
||||||
*
|
|
||||||
* 主要方法:
|
|
||||||
* - filterContent(): 内容过滤,敏感词检查
|
|
||||||
* - checkRateLimit(): 频率限制检查
|
|
||||||
* - validatePermission(): 权限验证,防止位置欺诈
|
|
||||||
* - logViolation(): 记录违规行为
|
|
||||||
*
|
|
||||||
* 使用场景:
|
|
||||||
* - 消息发送前的内容审核
|
|
||||||
* - 频率限制和防刷屏
|
|
||||||
* - 权限验证和安全控制
|
|
||||||
*
|
|
||||||
* 依赖模块:
|
|
||||||
* - AppLoggerService: 日志记录服务
|
|
||||||
* - IRedisService: Redis缓存服务
|
|
||||||
* - ConfigManagerService: 配置管理服务
|
|
||||||
*
|
|
||||||
* 最近修改:
|
|
||||||
* - 2026-01-12: 代码规范优化 - 处理TODO项,移除告警通知相关的TODO注释 (修改者: moyin)
|
|
||||||
* - 2026-01-07: 代码规范优化 - 清理未使用的导入(forwardRef) (修改者: moyin)
|
|
||||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
|
|
||||||
*
|
|
||||||
* @author angjustinl
|
|
||||||
* @version 1.1.3
|
|
||||||
* @since 2025-12-25
|
|
||||||
* @lastModified 2026-01-12
|
|
||||||
*/
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 权限验证结果接口
|
|
||||||
*/
|
|
||||||
export interface PermissionValidationResult {
|
|
||||||
allowed: boolean;
|
|
||||||
reason?: string;
|
|
||||||
expectedStream?: string;
|
|
||||||
actualStream?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 频率限制结果接口
|
|
||||||
*/
|
|
||||||
export interface RateLimitResult {
|
|
||||||
allowed: boolean;
|
|
||||||
currentCount: number;
|
|
||||||
limit: number;
|
|
||||||
remainingTime?: number;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 违规类型枚举
|
|
||||||
*/
|
|
||||||
export enum ViolationType {
|
|
||||||
CONTENT = 'content',
|
|
||||||
RATE = 'rate',
|
|
||||||
PERMISSION = 'permission',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 违规记录接口
|
|
||||||
*/
|
|
||||||
export interface ViolationRecord {
|
|
||||||
userId: string;
|
|
||||||
type: ViolationType;
|
|
||||||
details: any;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 敏感词配置接口
|
|
||||||
*/
|
|
||||||
export interface SensitiveWordConfig {
|
|
||||||
word: string;
|
|
||||||
level: 'block' | 'replace'; // block: 直接拒绝, replace: 替换为星号
|
|
||||||
category?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消息过滤服务类
|
|
||||||
*
|
|
||||||
* 职责:
|
|
||||||
* - 实施内容审核和频率控制
|
|
||||||
* - 敏感词过滤和权限验证
|
|
||||||
* - 防止恶意操作和滥用
|
|
||||||
* - 与ConfigManager集成实现位置权限验证
|
|
||||||
*
|
|
||||||
* 主要方法:
|
|
||||||
* - filterContent(): 内容过滤,敏感词检查
|
|
||||||
* - checkRateLimit(): 频率限制检查
|
|
||||||
* - validatePermission(): 权限验证,防止位置欺诈
|
|
||||||
* - validateMessage(): 综合消息验证
|
|
||||||
* - logViolation(): 记录违规行为
|
|
||||||
*
|
|
||||||
* 使用场景:
|
|
||||||
* - 消息发送前的内容审核
|
|
||||||
* - 频率限制和防刷屏
|
|
||||||
* - 权限验证和安全控制
|
|
||||||
* - 违规行为监控和记录
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MessageFilterService {
|
|
||||||
private readonly RATE_LIMIT_PREFIX = 'zulip:rate_limit:';
|
|
||||||
private readonly VIOLATION_PREFIX = 'zulip:violation:';
|
|
||||||
private readonly VIOLATION_COUNT_PREFIX = 'zulip:violation_count:';
|
|
||||||
private readonly DEFAULT_RATE_LIMIT = 10; // 每分钟最多10条消息
|
|
||||||
private readonly RATE_LIMIT_WINDOW = 60; // 60秒窗口
|
|
||||||
private readonly MAX_MESSAGE_LENGTH = 1000; // 最大消息长度
|
|
||||||
private readonly MIN_MESSAGE_LENGTH = 1; // 最小消息长度
|
|
||||||
private readonly logger = new Logger(MessageFilterService.name);
|
|
||||||
|
|
||||||
// 敏感词列表(可从配置文件或数据库加载)
|
|
||||||
private sensitiveWords: SensitiveWordConfig[] = [
|
|
||||||
{ word: '垃圾', level: 'replace', category: 'offensive' },
|
|
||||||
{ word: '广告', level: 'replace', category: 'spam' },
|
|
||||||
{ word: '刷屏', level: 'replace', category: 'spam' },
|
|
||||||
{ word: '傻逼', level: 'block', category: 'offensive' },
|
|
||||||
{ word: '操你', level: 'block', category: 'offensive' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 恶意链接黑名单域名
|
|
||||||
private readonly BLACKLISTED_DOMAINS = [
|
|
||||||
'malware.com',
|
|
||||||
'phishing.net',
|
|
||||||
'spam-site.org',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 允许的链接白名单域名
|
|
||||||
private readonly WHITELISTED_DOMAINS = [
|
|
||||||
'github.com',
|
|
||||||
'datawhale.club',
|
|
||||||
'zulip.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject('REDIS_SERVICE')
|
|
||||||
private readonly redisService: IRedisService,
|
|
||||||
@Inject('ZULIP_CONFIG_SERVICE')
|
|
||||||
private readonly configManager: IZulipConfigService,
|
|
||||||
) {
|
|
||||||
this.logger.log('MessageFilterService初始化完成');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 内容过滤 - 敏感词检查
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 检查消息内容是否包含敏感词,进行内容过滤和替换
|
|
||||||
*
|
|
||||||
* 业务逻辑:
|
|
||||||
* 1. 检查消息长度限制
|
|
||||||
* 2. 检查是否全为空白字符
|
|
||||||
* 3. 扫描敏感词列表(区分block和replace级别)
|
|
||||||
* 4. 检查重复字符和刷屏行为
|
|
||||||
* 5. 检查恶意链接
|
|
||||||
* 6. 返回过滤结果
|
|
||||||
*
|
|
||||||
* @param content 消息内容
|
|
||||||
* @returns Promise<ContentFilterResult> 过滤结果
|
|
||||||
*/
|
|
||||||
async filterContent(content: string): Promise<ContentFilterResult> {
|
|
||||||
this.logger.debug('开始内容过滤', {
|
|
||||||
operation: 'filterContent',
|
|
||||||
contentLength: content?.length || 0,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 检查消息是否为空
|
|
||||||
if (!content || content.trim().length === 0) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息内容不能为空',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查消息长度
|
|
||||||
if (content.length > this.MAX_MESSAGE_LENGTH) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `消息内容过长,最多${this.MAX_MESSAGE_LENGTH}字符`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.trim().length < this.MIN_MESSAGE_LENGTH) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息内容过短',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 检查是否全为空白字符
|
|
||||||
if (/^\s+$/.test(content)) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息不能只包含空白字符',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 敏感词检查
|
|
||||||
let filteredContent = content;
|
|
||||||
let hasBlockedWord = false;
|
|
||||||
let hasReplacedWord = false;
|
|
||||||
let blockedWord = '';
|
|
||||||
|
|
||||||
for (const wordConfig of this.sensitiveWords) {
|
|
||||||
if (content.toLowerCase().includes(wordConfig.word.toLowerCase())) {
|
|
||||||
if (wordConfig.level === 'block') {
|
|
||||||
hasBlockedWord = true;
|
|
||||||
blockedWord = wordConfig.word;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
hasReplacedWord = true;
|
|
||||||
// 替换敏感词为星号
|
|
||||||
const replacement = '*'.repeat(wordConfig.word.length);
|
|
||||||
filteredContent = filteredContent.replace(
|
|
||||||
new RegExp(this.escapeRegExp(wordConfig.word), 'gi'),
|
|
||||||
replacement
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果包含需要阻止的敏感词,直接拒绝
|
|
||||||
if (hasBlockedWord) {
|
|
||||||
this.logger.warn('消息包含禁止的敏感词', {
|
|
||||||
operation: 'filterContent',
|
|
||||||
blockedWord,
|
|
||||||
contentLength: content.length,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息包含不允许的内容',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 检查是否包含过多重复字符(防刷屏)
|
|
||||||
if (this.hasExcessiveRepetition(content)) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息包含过多重复字符',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 检查是否包含恶意链接
|
|
||||||
const linkCheckResult = this.checkLinks(content);
|
|
||||||
if (!linkCheckResult.allowed) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: linkCheckResult.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ContentFilterResult = {
|
|
||||||
allowed: true,
|
|
||||||
filtered: hasReplacedWord ? filteredContent : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.debug('内容过滤完成', {
|
|
||||||
operation: 'filterContent',
|
|
||||||
allowed: result.allowed,
|
|
||||||
hasReplacedWord,
|
|
||||||
originalLength: content.length,
|
|
||||||
filteredLength: filteredContent.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('内容过滤失败', {
|
|
||||||
operation: 'filterContent',
|
|
||||||
contentLength: content?.length || 0,
|
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}, err.stack);
|
|
||||||
|
|
||||||
// 过滤失败时默认拒绝
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '内容过滤失败,请稍后重试',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 频率限制检查
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 检查用户是否超过消息发送频率限制,防止刷屏
|
|
||||||
*
|
|
||||||
* 业务逻辑:
|
|
||||||
* 1. 获取用户当前发送计数
|
|
||||||
* 2. 检查是否超过限制
|
|
||||||
* 3. 更新发送计数
|
|
||||||
* 4. 返回检查结果
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @returns Promise<boolean> 是否允许发送(true表示允许)
|
|
||||||
*/
|
|
||||||
async checkRateLimit(userId: string): Promise<boolean> {
|
|
||||||
const result = await this.checkRateLimitDetailed(userId);
|
|
||||||
return result.allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 频率限制检查(详细版本)
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 检查用户是否超过消息发送频率限制,返回详细信息
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @param customLimit 自定义限制(可选)
|
|
||||||
* @returns Promise<RateLimitResult> 频率限制检查结果
|
|
||||||
*/
|
|
||||||
async checkRateLimitDetailed(userId: string, customLimit?: number): Promise<RateLimitResult> {
|
|
||||||
this.logger.debug('开始频率限制检查', {
|
|
||||||
operation: 'checkRateLimitDetailed',
|
|
||||||
userId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const limit = customLimit || this.DEFAULT_RATE_LIMIT;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`;
|
|
||||||
|
|
||||||
// 获取当前计数
|
|
||||||
const currentCount = await this.redisService.get(rateLimitKey);
|
|
||||||
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
|
||||||
|
|
||||||
// 检查是否超过限制
|
|
||||||
if (count >= limit) {
|
|
||||||
this.logger.warn('用户超过频率限制', {
|
|
||||||
operation: 'checkRateLimitDetailed',
|
|
||||||
userId,
|
|
||||||
currentCount: count,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取剩余时间
|
|
||||||
const ttl = await this.redisService.ttl(rateLimitKey);
|
|
||||||
|
|
||||||
// 记录违规行为
|
|
||||||
await this.logViolation(userId, ViolationType.RATE, {
|
|
||||||
currentCount: count,
|
|
||||||
limit,
|
|
||||||
remainingTime: ttl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
currentCount: count,
|
|
||||||
limit,
|
|
||||||
remainingTime: ttl > 0 ? ttl : undefined,
|
|
||||||
reason: `发送频率过高,请${ttl > 0 ? `${ttl}秒后` : '稍后'}重试`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 增加计数
|
|
||||||
if (count === 0) {
|
|
||||||
// 首次发送,设置计数和过期时间
|
|
||||||
await this.redisService.setex(rateLimitKey, this.RATE_LIMIT_WINDOW, '1');
|
|
||||||
} else {
|
|
||||||
// 增加计数
|
|
||||||
await this.redisService.incr(rateLimitKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug('频率限制检查通过', {
|
|
||||||
operation: 'checkRateLimitDetailed',
|
|
||||||
userId,
|
|
||||||
newCount: count + 1,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
currentCount: count + 1,
|
|
||||||
limit,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('频率限制检查失败', {
|
|
||||||
operation: 'checkRateLimitDetailed',
|
|
||||||
userId,
|
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}, err.stack);
|
|
||||||
|
|
||||||
// 检查失败时默认允许,避免影响正常用户
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
currentCount: 0,
|
|
||||||
limit,
|
|
||||||
reason: '频率检查服务暂时不可用',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 权限验证 - 防止位置欺诈
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 验证用户是否有权限向目标Stream发送消息,防止位置欺诈
|
|
||||||
* 使用ConfigManager获取地图到Stream的映射关系
|
|
||||||
*
|
|
||||||
* 业务逻辑:
|
|
||||||
* 1. 从ConfigManager获取地图到Stream的映射
|
|
||||||
* 2. 检查目标Stream是否匹配当前地图
|
|
||||||
* 3. 检查用户是否有特殊权限(如管理员)
|
|
||||||
* 4. 返回验证结果
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @param targetStream 目标Stream名称
|
|
||||||
* @param currentMap 当前地图ID
|
|
||||||
* @returns Promise<boolean> 是否有权限(true表示有权限)
|
|
||||||
*/
|
|
||||||
async validatePermission(userId: string, targetStream: string, currentMap: string): Promise<boolean> {
|
|
||||||
const result = await this.validatePermissionDetailed(userId, targetStream, currentMap);
|
|
||||||
return result.allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 权限验证(详细版本)
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 验证用户是否有权限向目标Stream发送消息,返回详细信息
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @param targetStream 目标Stream名称
|
|
||||||
* @param currentMap 当前地图ID
|
|
||||||
* @returns Promise<PermissionValidationResult> 权限验证结果
|
|
||||||
*/
|
|
||||||
async validatePermissionDetailed(
|
|
||||||
userId: string,
|
|
||||||
targetStream: string,
|
|
||||||
currentMap: string
|
|
||||||
): Promise<PermissionValidationResult> {
|
|
||||||
this.logger.debug('开始权限验证', {
|
|
||||||
operation: 'validatePermissionDetailed',
|
|
||||||
userId,
|
|
||||||
targetStream,
|
|
||||||
currentMap,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 参数验证
|
|
||||||
if (!userId || !userId.trim()) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '用户ID无效',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetStream || !targetStream.trim()) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '目标Stream无效',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentMap || !currentMap.trim()) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '当前地图无效',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 从ConfigManager获取地图对应的Stream
|
|
||||||
const allowedStream = this.configManager.getStreamByMap(currentMap);
|
|
||||||
|
|
||||||
if (!allowedStream) {
|
|
||||||
this.logger.warn('未知地图,拒绝发送', {
|
|
||||||
operation: 'validatePermissionDetailed',
|
|
||||||
userId,
|
|
||||||
currentMap,
|
|
||||||
targetStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.logViolation(userId, ViolationType.PERMISSION, {
|
|
||||||
reason: 'unknown_map',
|
|
||||||
currentMap,
|
|
||||||
targetStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '当前地图未配置对应的聊天频道',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 检查目标Stream是否匹配(不区分大小写)
|
|
||||||
if (targetStream.toLowerCase() !== allowedStream.toLowerCase()) {
|
|
||||||
this.logger.warn('位置与目标Stream不匹配', {
|
|
||||||
operation: 'validatePermissionDetailed',
|
|
||||||
userId,
|
|
||||||
currentMap,
|
|
||||||
targetStream,
|
|
||||||
allowedStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.logViolation(userId, ViolationType.PERMISSION, {
|
|
||||||
reason: 'location_mismatch',
|
|
||||||
currentMap,
|
|
||||||
targetStream,
|
|
||||||
allowedStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '您当前位置无法向该频道发送消息',
|
|
||||||
expectedStream: allowedStream,
|
|
||||||
actualStream: targetStream,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug('权限验证通过', {
|
|
||||||
operation: 'validatePermissionDetailed',
|
|
||||||
userId,
|
|
||||||
targetStream,
|
|
||||||
currentMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
expectedStream: allowedStream,
|
|
||||||
actualStream: targetStream,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('权限验证失败', {
|
|
||||||
operation: 'validatePermissionDetailed',
|
|
||||||
userId,
|
|
||||||
targetStream,
|
|
||||||
currentMap,
|
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}, err.stack);
|
|
||||||
|
|
||||||
// 验证失败时默认拒绝
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '权限验证服务暂时不可用',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 综合消息验证
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 对消息进行综合验证,包括内容过滤、频率限制和权限验证
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @param content 消息内容
|
|
||||||
* @param targetStream 目标Stream
|
|
||||||
* @param currentMap 当前地图
|
|
||||||
* @returns Promise<{allowed: boolean, reason?: string, filteredContent?: string}>
|
|
||||||
*/
|
|
||||||
async validateMessage(
|
|
||||||
userId: string,
|
|
||||||
content: string,
|
|
||||||
targetStream: string,
|
|
||||||
currentMap: string
|
|
||||||
): Promise<{
|
|
||||||
allowed: boolean;
|
|
||||||
reason?: string;
|
|
||||||
filteredContent?: string;
|
|
||||||
}> {
|
|
||||||
this.logger.debug('开始综合消息验证', {
|
|
||||||
operation: 'validateMessage',
|
|
||||||
userId,
|
|
||||||
contentLength: content?.length || 0,
|
|
||||||
targetStream,
|
|
||||||
currentMap,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 频率限制检查
|
|
||||||
const rateLimitResult = await this.checkRateLimitDetailed(userId);
|
|
||||||
if (!rateLimitResult.allowed) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: rateLimitResult.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 内容过滤
|
|
||||||
const contentResult = await this.filterContent(content);
|
|
||||||
if (!contentResult.allowed) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: contentResult.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 权限验证
|
|
||||||
const permissionResult = await this.validatePermissionDetailed(userId, targetStream, currentMap);
|
|
||||||
if (!permissionResult.allowed) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: permissionResult.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log('消息验证通过', {
|
|
||||||
operation: 'validateMessage',
|
|
||||||
userId,
|
|
||||||
targetStream,
|
|
||||||
currentMap,
|
|
||||||
hasFilteredContent: !!contentResult.filtered,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
filteredContent: contentResult.filtered,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('综合消息验证失败', {
|
|
||||||
operation: 'validateMessage',
|
|
||||||
userId,
|
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}, err.stack);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息验证失败,请稍后重试',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 记录违规行为
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 记录用户的违规行为,用于监控和分析
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @param type 违规类型
|
|
||||||
* @param details 违规详情
|
|
||||||
* @returns Promise<void>
|
|
||||||
*/
|
|
||||||
async logViolation(userId: string, type: ViolationType, details: any): Promise<void> {
|
|
||||||
this.logger.warn('记录违规行为', {
|
|
||||||
operation: 'logViolation',
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
details,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const violation: ViolationRecord = {
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
details,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 存储违规记录到Redis(保留7天)
|
|
||||||
const violationKey = `${this.VIOLATION_PREFIX}${userId}:${Date.now()}`;
|
|
||||||
await this.redisService.setex(violationKey, 7 * 24 * 3600, JSON.stringify(violation));
|
|
||||||
|
|
||||||
// 后续版本可以考虑发送告警通知或更新用户信誉度
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('记录违规行为失败', {
|
|
||||||
operation: 'logViolation',
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}, err.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否包含过多重复字符
|
|
||||||
*
|
|
||||||
* @param content 消息内容
|
|
||||||
* @returns boolean 是否包含过多重复字符
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private hasExcessiveRepetition(content: string): boolean {
|
|
||||||
// 检查连续重复字符(超过5个相同字符)
|
|
||||||
const repetitionPattern = /(.)\1{4,}/;
|
|
||||||
if (repetitionPattern.test(content)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查重复短语(同一个词重复超过3次)
|
|
||||||
const words = content.split(/\s+/);
|
|
||||||
const wordCount = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const word of words) {
|
|
||||||
if (word.length > 1) {
|
|
||||||
const normalizedWord = word.toLowerCase();
|
|
||||||
const count = (wordCount.get(normalizedWord) || 0) + 1;
|
|
||||||
wordCount.set(normalizedWord, count);
|
|
||||||
|
|
||||||
if (count > 3) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查连续重复的短语模式(如 "哈哈哈哈哈")
|
|
||||||
const phrasePattern = /(.{2,})\1{2,}/;
|
|
||||||
if (phrasePattern.test(content)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查链接安全性
|
|
||||||
*
|
|
||||||
* @param content 消息内容
|
|
||||||
* @returns {allowed: boolean, reason?: string} 检查结果
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private checkLinks(content: string): { allowed: boolean; reason?: string } {
|
|
||||||
// 提取所有URL
|
|
||||||
const urlPattern = /(https?:\/\/[^\s]+)/gi;
|
|
||||||
const urls = content.match(urlPattern);
|
|
||||||
|
|
||||||
if (!urls || urls.length === 0) {
|
|
||||||
return { allowed: 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 {
|
|
||||||
allowed: false,
|
|
||||||
reason: '消息包含不允许的链接',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可选:只允许白名单域名
|
|
||||||
// const isWhitelisted = this.WHITELISTED_DOMAINS.some(
|
|
||||||
// whitelisted => domain.includes(whitelisted)
|
|
||||||
// );
|
|
||||||
// if (!isWhitelisted) {
|
|
||||||
// return {
|
|
||||||
// allowed: false,
|
|
||||||
// reason: '消息包含未授权的链接',
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
// URL解析失败,可能是格式不正确的链接
|
|
||||||
// 暂时允许,避免误判
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 转义正则表达式特殊字符
|
|
||||||
*
|
|
||||||
* @param string 要转义的字符串
|
|
||||||
* @returns string 转义后的字符串
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private escapeRegExp(string: string): string {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户违规统计
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @returns Promise<{totalViolations: number, recentViolations: number, violationsByType: Record<string, number>}>
|
|
||||||
*/
|
|
||||||
async getUserViolationStats(userId: string): Promise<{
|
|
||||||
totalViolations: number;
|
|
||||||
recentViolations: number;
|
|
||||||
violationsByType: Record<string, number>;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// 获取违规计数
|
|
||||||
const countKey = `${this.VIOLATION_COUNT_PREFIX}${userId}`;
|
|
||||||
const totalCount = await this.redisService.get(countKey);
|
|
||||||
|
|
||||||
// 获取最近24小时的违规记录
|
|
||||||
const now = Date.now();
|
|
||||||
const oneDayAgo = now - 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// 统计各类型违规
|
|
||||||
const violationsByType: Record<string, number> = {
|
|
||||||
[ViolationType.CONTENT]: 0,
|
|
||||||
[ViolationType.RATE]: 0,
|
|
||||||
[ViolationType.PERMISSION]: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 注意:这里简化了实现,实际应该使用Redis的有序集合来存储和查询违规记录
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalViolations: totalCount ? parseInt(totalCount, 10) : 0,
|
|
||||||
recentViolations: 0, // 需要更复杂的实现
|
|
||||||
violationsByType,
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('获取用户违规统计失败', {
|
|
||||||
operation: 'getUserViolationStats',
|
|
||||||
userId,
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalViolations: 0,
|
|
||||||
recentViolations: 0,
|
|
||||||
violationsByType: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置用户频率限制
|
|
||||||
*
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @returns Promise<void>
|
|
||||||
*/
|
|
||||||
async resetUserRateLimit(userId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`;
|
|
||||||
await this.redisService.del(rateLimitKey);
|
|
||||||
|
|
||||||
this.logger.log('重置用户频率限制', {
|
|
||||||
operation: 'resetUserRateLimit',
|
|
||||||
userId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
this.logger.error('重置用户频率限制失败', {
|
|
||||||
operation: 'resetUserRateLimit',
|
|
||||||
userId,
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加敏感词
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 动态添加敏感词到过滤列表
|
|
||||||
*
|
|
||||||
* @param word 敏感词
|
|
||||||
* @param level 过滤级别
|
|
||||||
* @param category 分类(可选)
|
|
||||||
* @returns void
|
|
||||||
*/
|
|
||||||
addSensitiveWord(word: string, level: 'block' | 'replace', category?: string): void {
|
|
||||||
if (!word || !word.trim()) {
|
|
||||||
this.logger.warn('添加敏感词失败:词为空', {
|
|
||||||
operation: 'addSensitiveWord',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否已存在
|
|
||||||
const exists = this.sensitiveWords.some(
|
|
||||||
w => w.word.toLowerCase() === word.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
this.logger.debug('敏感词已存在', {
|
|
||||||
operation: 'addSensitiveWord',
|
|
||||||
word,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sensitiveWords.push({
|
|
||||||
word: word.trim(),
|
|
||||||
level,
|
|
||||||
category,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log('添加敏感词成功', {
|
|
||||||
operation: 'addSensitiveWord',
|
|
||||||
word,
|
|
||||||
level,
|
|
||||||
category,
|
|
||||||
totalCount: this.sensitiveWords.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除敏感词
|
|
||||||
*
|
|
||||||
* @param word 敏感词
|
|
||||||
* @returns boolean 是否成功移除
|
|
||||||
*/
|
|
||||||
removeSensitiveWord(word: string): boolean {
|
|
||||||
const index = this.sensitiveWords.findIndex(
|
|
||||||
w => w.word.toLowerCase() === word.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sensitiveWords.splice(index, 1);
|
|
||||||
|
|
||||||
this.logger.log('移除敏感词成功', {
|
|
||||||
operation: 'removeSensitiveWord',
|
|
||||||
word,
|
|
||||||
totalCount: this.sensitiveWords.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取敏感词列表
|
|
||||||
*
|
|
||||||
* @returns SensitiveWordConfig[] 敏感词配置列表
|
|
||||||
*/
|
|
||||||
getSensitiveWords(): SensitiveWordConfig[] {
|
|
||||||
return [...this.sensitiveWords];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取过滤服务统计信息
|
|
||||||
*
|
|
||||||
* @returns 统计信息
|
|
||||||
*/
|
|
||||||
getFilterStats(): {
|
|
||||||
sensitiveWordsCount: number;
|
|
||||||
blacklistedDomainsCount: number;
|
|
||||||
whitelistedDomainsCount: number;
|
|
||||||
rateLimit: number;
|
|
||||||
rateLimitWindow: number;
|
|
||||||
maxMessageLength: number;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
sensitiveWordsCount: this.sensitiveWords.length,
|
|
||||||
blacklistedDomainsCount: this.BLACKLISTED_DOMAINS.length,
|
|
||||||
whitelistedDomainsCount: this.WHITELISTED_DOMAINS.length,
|
|
||||||
rateLimit: this.DEFAULT_RATE_LIMIT,
|
|
||||||
rateLimitWindow: this.RATE_LIMIT_WINDOW,
|
|
||||||
maxMessageLength: this.MAX_MESSAGE_LENGTH,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
/**
|
|
||||||
* 会话清理定时任务服务测试
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 测试SessionCleanupService的核心功能
|
|
||||||
* - 包含属性测试验证定时清理机制
|
|
||||||
* - 包含属性测试验证资源释放完整性
|
|
||||||
*
|
|
||||||
* **Feature: zulip-integration, Property 13: 定时清理机制**
|
|
||||||
* **Validates: Requirements 6.1, 6.2, 6.3**
|
|
||||||
*
|
|
||||||
* **Feature: zulip-integration, Property 14: 资源释放完整性**
|
|
||||||
* **Validates: Requirements 6.4, 6.5**
|
|
||||||
*
|
|
||||||
* @author angjustinl
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2025-12-31
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import * as fc from 'fast-check';
|
|
||||||
import {
|
|
||||||
SessionCleanupService,
|
|
||||||
CleanupConfig,
|
|
||||||
CleanupResult
|
|
||||||
} from './session_cleanup.service';
|
|
||||||
import { SessionManagerService } from './session_manager.service';
|
|
||||||
import { IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces';
|
|
||||||
|
|
||||||
describe('SessionCleanupService', () => {
|
|
||||||
let service: SessionCleanupService;
|
|
||||||
let mockSessionManager: jest.Mocked<SessionManagerService>;
|
|
||||||
let mockZulipClientPool: jest.Mocked<IZulipClientPoolService>;
|
|
||||||
|
|
||||||
// 模拟清理结果
|
|
||||||
const createMockCleanupResult = (overrides: Partial<any> = {}): any => ({
|
|
||||||
cleanedCount: 3,
|
|
||||||
zulipQueueIds: ['queue-1', 'queue-2', 'queue-3'],
|
|
||||||
duration: 150,
|
|
||||||
timestamp: new Date(),
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.clearAllTimers();
|
|
||||||
// 确保每个测试开始时都使用真实定时器
|
|
||||||
jest.useRealTimers();
|
|
||||||
|
|
||||||
mockSessionManager = {
|
|
||||||
cleanupExpiredSessions: jest.fn(),
|
|
||||||
getSession: jest.fn(),
|
|
||||||
destroySession: jest.fn(),
|
|
||||||
createSession: jest.fn(),
|
|
||||||
updatePlayerPosition: jest.fn(),
|
|
||||||
getSocketsInMap: jest.fn(),
|
|
||||||
injectContext: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
mockZulipClientPool = {
|
|
||||||
createUserClient: jest.fn(),
|
|
||||||
getUserClient: jest.fn(),
|
|
||||||
hasUserClient: jest.fn(),
|
|
||||||
sendMessage: jest.fn(),
|
|
||||||
registerEventQueue: jest.fn(),
|
|
||||||
deregisterEventQueue: jest.fn(),
|
|
||||||
destroyUserClient: jest.fn(),
|
|
||||||
getPoolStats: jest.fn(),
|
|
||||||
cleanupIdleClients: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
SessionCleanupService,
|
|
||||||
{
|
|
||||||
provide: SessionManagerService,
|
|
||||||
useValue: mockSessionManager,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
|
||||||
useValue: mockZulipClientPool,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<SessionCleanupService>(SessionCleanupService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
// 确保停止所有清理任务
|
|
||||||
service.stopCleanupTask();
|
|
||||||
|
|
||||||
// 等待任何正在进行的异步操作完成
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
|
|
||||||
// 清理定时器
|
|
||||||
jest.clearAllTimers();
|
|
||||||
|
|
||||||
// 恢复真实定时器
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('startCleanupTask - 启动清理任务', () => {
|
|
||||||
it('应该启动定时清理任务', () => {
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在已启动时不重复启动', () => {
|
|
||||||
service.startCleanupTask();
|
|
||||||
service.startCleanupTask(); // 第二次调用
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该立即执行一次清理', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(
|
|
||||||
createMockCleanupResult({ cleanedCount: 2 })
|
|
||||||
);
|
|
||||||
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
// 等待立即执行的清理完成
|
|
||||||
await jest.runOnlyPendingTimersAsync();
|
|
||||||
|
|
||||||
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
|
||||||
|
|
||||||
// 确保清理任务被停止
|
|
||||||
service.stopCleanupTask();
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('stopCleanupTask - 停止清理任务', () => {
|
|
||||||
it('应该停止定时清理任务', () => {
|
|
||||||
service.startCleanupTask();
|
|
||||||
service.stopCleanupTask();
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在未启动时安全停止', () => {
|
|
||||||
service.stopCleanupTask();
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runCleanup - 执行清理', () => {
|
|
||||||
it('应该成功执行清理并返回结果', async () => {
|
|
||||||
const mockResult = createMockCleanupResult({
|
|
||||||
cleanedCount: 5,
|
|
||||||
zulipQueueIds: ['queue-1', 'queue-2', 'queue-3', 'queue-4', 'queue-5'],
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
|
|
||||||
|
|
||||||
const result = await service.runCleanup();
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.cleanedSessions).toBe(5);
|
|
||||||
expect(result.deregisteredQueues).toBe(5);
|
|
||||||
expect(result.duration).toBeGreaterThanOrEqual(0); // 修改为 >= 0,因为测试环境可能很快
|
|
||||||
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理清理过程中的错误', async () => {
|
|
||||||
const error = new Error('清理失败');
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await service.runCleanup();
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('清理失败');
|
|
||||||
expect(result.cleanedSessions).toBe(0);
|
|
||||||
expect(result.deregisteredQueues).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该防止并发执行', async () => {
|
|
||||||
let resolveFirst: () => void;
|
|
||||||
const firstPromise = new Promise<any>(resolve => {
|
|
||||||
resolveFirst = () => resolve(createMockCleanupResult());
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockReturnValueOnce(firstPromise);
|
|
||||||
|
|
||||||
// 同时启动两个清理任务
|
|
||||||
const promise1 = service.runCleanup();
|
|
||||||
const promise2 = service.runCleanup();
|
|
||||||
|
|
||||||
// 第二个应该立即返回失败
|
|
||||||
const result2 = await promise2;
|
|
||||||
expect(result2.success).toBe(false);
|
|
||||||
expect(result2.error).toContain('正在执行中');
|
|
||||||
|
|
||||||
// 完成第一个任务
|
|
||||||
resolveFirst!();
|
|
||||||
const result1 = await promise1;
|
|
||||||
expect(result1.success).toBe(true);
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
it('应该记录最后一次清理结果', async () => {
|
|
||||||
const mockResult = createMockCleanupResult({ cleanedCount: 3 });
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
|
|
||||||
|
|
||||||
await service.runCleanup();
|
|
||||||
|
|
||||||
const lastResult = service.getLastCleanupResult();
|
|
||||||
expect(lastResult).not.toBeNull();
|
|
||||||
expect(lastResult!.cleanedSessions).toBe(3);
|
|
||||||
expect(lastResult!.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getStatus - 获取状态', () => {
|
|
||||||
it('应该返回正确的状态信息', () => {
|
|
||||||
const status = service.getStatus();
|
|
||||||
|
|
||||||
expect(status).toHaveProperty('isRunning');
|
|
||||||
expect(status).toHaveProperty('isEnabled');
|
|
||||||
expect(status).toHaveProperty('config');
|
|
||||||
expect(status).toHaveProperty('lastResult');
|
|
||||||
expect(typeof status.isRunning).toBe('boolean');
|
|
||||||
expect(typeof status.isEnabled).toBe('boolean');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该反映任务启动状态', () => {
|
|
||||||
let status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(false);
|
|
||||||
|
|
||||||
service.startCleanupTask();
|
|
||||||
status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
|
|
||||||
service.stopCleanupTask();
|
|
||||||
status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateConfig - 更新配置', () => {
|
|
||||||
it('应该更新清理配置', () => {
|
|
||||||
const newConfig: Partial<CleanupConfig> = {
|
|
||||||
intervalMs: 10 * 60 * 1000, // 10分钟
|
|
||||||
sessionTimeoutMinutes: 60, // 60分钟
|
|
||||||
};
|
|
||||||
|
|
||||||
service.updateConfig(newConfig);
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.config.intervalMs).toBe(10 * 60 * 1000);
|
|
||||||
expect(status.config.sessionTimeoutMinutes).toBe(60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在配置更改后重启任务', () => {
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
const newConfig: Partial<CleanupConfig> = {
|
|
||||||
intervalMs: 2 * 60 * 1000, // 2分钟
|
|
||||||
};
|
|
||||||
|
|
||||||
service.updateConfig(newConfig);
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
expect(status.config.intervalMs).toBe(2 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该支持禁用清理任务', () => {
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
service.updateConfig({ enabled: false });
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* 属性测试: 定时清理机制
|
|
||||||
*
|
|
||||||
* **Feature: zulip-integration, Property 13: 定时清理机制**
|
|
||||||
* **Validates: Requirements 6.1, 6.2, 6.3**
|
|
||||||
*
|
|
||||||
* 系统应该定期清理过期的游戏会话,释放相关资源,
|
|
||||||
* 并确保清理过程不影响正常的游戏服务
|
|
||||||
*/
|
|
||||||
describe('Property 13: 定时清理机制', () => {
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何有效的清理配置,系统应该按配置间隔执行清理
|
|
||||||
* 验证需求 6.1: 系统应定期检查并清理过期的游戏会话
|
|
||||||
*/
|
|
||||||
it('对于任何有效的清理配置,系统应该按配置间隔执行清理', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成有效的清理间隔(1-5分钟,减少范围)
|
|
||||||
fc.integer({ min: 1, max: 5 }).map(minutes => minutes * 60 * 1000),
|
|
||||||
// 生成有效的会话超时时间(10-60分钟,减少范围)
|
|
||||||
fc.integer({ min: 10, max: 60 }),
|
|
||||||
async (intervalMs, sessionTimeoutMinutes) => {
|
|
||||||
// 重置mock以确保每次测试都是干净的状态
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.useFakeTimers();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config: Partial<CleanupConfig> = {
|
|
||||||
intervalMs,
|
|
||||||
sessionTimeoutMinutes,
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 模拟清理结果
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(
|
|
||||||
createMockCleanupResult({ cleanedCount: 2 })
|
|
||||||
);
|
|
||||||
|
|
||||||
service.updateConfig(config);
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
// 验证配置被正确设置
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.config.intervalMs).toBe(intervalMs);
|
|
||||||
expect(status.config.sessionTimeoutMinutes).toBe(sessionTimeoutMinutes);
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
|
|
||||||
// 验证立即执行了一次清理
|
|
||||||
await jest.runOnlyPendingTimersAsync();
|
|
||||||
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(sessionTimeoutMinutes);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
service.stopCleanupTask();
|
|
||||||
jest.useRealTimers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, timeout: 5000 } // 减少运行次数并添加超时
|
|
||||||
);
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何清理操作,都应该记录清理结果和统计信息
|
|
||||||
* 验证需求 6.2: 清理过程中系统应记录清理的会话数量和释放的资源
|
|
||||||
*/
|
|
||||||
it('对于任何清理操作,都应该记录清理结果和统计信息', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成清理的会话数量
|
|
||||||
fc.integer({ min: 0, max: 10 }),
|
|
||||||
// 生成Zulip队列ID列表
|
|
||||||
fc.array(
|
|
||||||
fc.string({ minLength: 5, maxLength: 15 }).filter(s => s.trim().length > 0),
|
|
||||||
{ minLength: 0, maxLength: 10 }
|
|
||||||
),
|
|
||||||
async (cleanedCount, queueIds) => {
|
|
||||||
// 重置mock以确保每次测试都是干净的状态
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
const mockResult = createMockCleanupResult({
|
|
||||||
cleanedCount,
|
|
||||||
zulipQueueIds: queueIds.slice(0, cleanedCount), // 确保队列数量不超过清理数量
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
|
|
||||||
|
|
||||||
const result = await service.runCleanup();
|
|
||||||
|
|
||||||
// 验证清理结果被正确记录
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.cleanedSessions).toBe(cleanedCount);
|
|
||||||
expect(result.deregisteredQueues).toBe(Math.min(queueIds.length, cleanedCount));
|
|
||||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(result.timestamp).toBeInstanceOf(Date);
|
|
||||||
|
|
||||||
// 验证最后一次清理结果被保存
|
|
||||||
const lastResult = service.getLastCleanupResult();
|
|
||||||
expect(lastResult).not.toBeNull();
|
|
||||||
expect(lastResult!.cleanedSessions).toBe(cleanedCount);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时
|
|
||||||
);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 清理过程中发生错误时,系统应该正确处理并记录错误信息
|
|
||||||
* 验证需求 6.3: 清理过程中出现错误时系统应记录错误信息并继续正常服务
|
|
||||||
*/
|
|
||||||
it('清理过程中发生错误时,系统应该正确处理并记录错误信息', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成各种错误消息
|
|
||||||
fc.string({ minLength: 5, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
async (errorMessage) => {
|
|
||||||
// 重置mock以确保每次测试都是干净的状态
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
const error = new Error(errorMessage.trim());
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await service.runCleanup();
|
|
||||||
|
|
||||||
// 验证错误被正确处理
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe(errorMessage.trim());
|
|
||||||
expect(result.cleanedSessions).toBe(0);
|
|
||||||
expect(result.deregisteredQueues).toBe(0);
|
|
||||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
||||||
|
|
||||||
// 验证错误结果被保存
|
|
||||||
const lastResult = service.getLastCleanupResult();
|
|
||||||
expect(lastResult).not.toBeNull();
|
|
||||||
expect(lastResult!.success).toBe(false);
|
|
||||||
expect(lastResult!.error).toBe(errorMessage.trim());
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时
|
|
||||||
);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 并发清理请求应该被正确处理,避免重复执行
|
|
||||||
* 验证需求 6.1: 系统应避免同时执行多个清理任务
|
|
||||||
*/
|
|
||||||
it('并发清理请求应该被正确处理,避免重复执行', async () => {
|
|
||||||
// 重置mock
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
// 创建一个可控的Promise,使用实际的异步行为
|
|
||||||
let resolveCleanup: (value: any) => void;
|
|
||||||
const cleanupPromise = new Promise<any>(resolve => {
|
|
||||||
resolveCleanup = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockReturnValue(cleanupPromise);
|
|
||||||
|
|
||||||
// 启动第一个清理请求(应该成功)
|
|
||||||
const promise1 = service.runCleanup();
|
|
||||||
|
|
||||||
// 等待一个微任务周期,确保第一个请求开始执行
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
// 启动第二个和第三个清理请求(应该被拒绝)
|
|
||||||
const promise2 = service.runCleanup();
|
|
||||||
const promise3 = service.runCleanup();
|
|
||||||
|
|
||||||
// 第二个和第三个请求应该立即返回失败
|
|
||||||
const result2 = await promise2;
|
|
||||||
const result3 = await promise3;
|
|
||||||
|
|
||||||
expect(result2.success).toBe(false);
|
|
||||||
expect(result2.error).toContain('正在执行中');
|
|
||||||
expect(result3.success).toBe(false);
|
|
||||||
expect(result3.error).toContain('正在执行中');
|
|
||||||
|
|
||||||
// 完成第一个清理操作
|
|
||||||
resolveCleanup!(createMockCleanupResult({ cleanedCount: 1 }));
|
|
||||||
const result1 = await promise1;
|
|
||||||
|
|
||||||
expect(result1.success).toBe(true);
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* 属性测试: 资源释放完整性
|
|
||||||
*
|
|
||||||
* **Feature: zulip-integration, Property 14: 资源释放完整性**
|
|
||||||
* **Validates: Requirements 6.4, 6.5**
|
|
||||||
*
|
|
||||||
* 清理过期会话时,系统应该完整释放所有相关资源,
|
|
||||||
* 包括Zulip事件队列、内存缓存等,确保不会造成资源泄漏
|
|
||||||
*/
|
|
||||||
describe('Property 14: 资源释放完整性', () => {
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何过期会话,清理时应该释放所有相关的Zulip资源
|
|
||||||
* 验证需求 6.4: 清理会话时系统应注销对应的Zulip事件队列
|
|
||||||
*/
|
|
||||||
it('对于任何过期会话,清理时应该释放所有相关的Zulip资源', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成过期会话数量
|
|
||||||
fc.integer({ min: 1, max: 5 }),
|
|
||||||
// 生成每个会话对应的Zulip队列ID
|
|
||||||
fc.array(
|
|
||||||
fc.string({ minLength: 8, maxLength: 15 }).filter(s => s.trim().length > 0),
|
|
||||||
{ minLength: 1, maxLength: 5 }
|
|
||||||
),
|
|
||||||
async (sessionCount, queueIds) => {
|
|
||||||
// 重置mock以确保每次测试都是干净的状态
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
const actualQueueIds = queueIds.slice(0, sessionCount);
|
|
||||||
const mockResult = createMockCleanupResult({
|
|
||||||
cleanedCount: sessionCount,
|
|
||||||
zulipQueueIds: actualQueueIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
|
|
||||||
|
|
||||||
const result = await service.runCleanup();
|
|
||||||
|
|
||||||
// 验证清理成功
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.cleanedSessions).toBe(sessionCount);
|
|
||||||
|
|
||||||
// 验证Zulip队列被处理(这里简化为计数验证)
|
|
||||||
expect(result.deregisteredQueues).toBe(actualQueueIds.length);
|
|
||||||
|
|
||||||
// 验证SessionManager被调用清理过期会话
|
|
||||||
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时
|
|
||||||
);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 清理操作应该是原子性的,要么全部成功要么全部回滚
|
|
||||||
* 验证需求 6.5: 清理过程应确保数据一致性,避免部分清理导致的不一致状态
|
|
||||||
*/
|
|
||||||
it('清理操作应该是原子性的,要么全部成功要么全部回滚', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成是否模拟清理失败
|
|
||||||
fc.boolean(),
|
|
||||||
// 生成会话数量
|
|
||||||
fc.integer({ min: 1, max: 3 }),
|
|
||||||
async (shouldFail, sessionCount) => {
|
|
||||||
// 重置mock以确保每次测试都是干净的状态
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
if (shouldFail) {
|
|
||||||
// 模拟清理失败
|
|
||||||
const error = new Error('清理操作失败');
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error);
|
|
||||||
} else {
|
|
||||||
// 模拟清理成功
|
|
||||||
const mockResult = createMockCleanupResult({
|
|
||||||
cleanedCount: sessionCount,
|
|
||||||
zulipQueueIds: Array.from({ length: sessionCount }, (_, i) => `queue-${i}`),
|
|
||||||
});
|
|
||||||
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await service.runCleanup();
|
|
||||||
|
|
||||||
if (shouldFail) {
|
|
||||||
// 失败时应该没有任何资源被释放
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.cleanedSessions).toBe(0);
|
|
||||||
expect(result.deregisteredQueues).toBe(0);
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
} else {
|
|
||||||
// 成功时所有资源都应该被正确处理
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.cleanedSessions).toBe(sessionCount);
|
|
||||||
expect(result.deregisteredQueues).toBe(sessionCount);
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证结果的一致性
|
|
||||||
expect(result.timestamp).toBeInstanceOf(Date);
|
|
||||||
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时
|
|
||||||
);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 清理配置更新应该正确重启清理任务而不丢失状态
|
|
||||||
* 验证需求 6.5: 配置更新时系统应保持服务连续性
|
|
||||||
*/
|
|
||||||
it('清理配置更新应该正确重启清理任务而不丢失状态', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成初始配置
|
|
||||||
fc.record({
|
|
||||||
intervalMs: fc.integer({ min: 1, max: 3 }).map(m => m * 60 * 1000),
|
|
||||||
sessionTimeoutMinutes: fc.integer({ min: 10, max: 30 }),
|
|
||||||
}),
|
|
||||||
// 生成新配置
|
|
||||||
fc.record({
|
|
||||||
intervalMs: fc.integer({ min: 1, max: 3 }).map(m => m * 60 * 1000),
|
|
||||||
sessionTimeoutMinutes: fc.integer({ min: 10, max: 30 }),
|
|
||||||
}),
|
|
||||||
async (initialConfig, newConfig) => {
|
|
||||||
// 重置mock以确保每次测试都是干净的状态
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 设置初始配置并启动任务
|
|
||||||
service.updateConfig(initialConfig);
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
let status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
expect(status.config.intervalMs).toBe(initialConfig.intervalMs);
|
|
||||||
|
|
||||||
// 更新配置
|
|
||||||
service.updateConfig(newConfig);
|
|
||||||
|
|
||||||
// 验证配置更新后任务仍在运行
|
|
||||||
status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
expect(status.config.intervalMs).toBe(newConfig.intervalMs);
|
|
||||||
expect(status.config.sessionTimeoutMinutes).toBe(newConfig.sessionTimeoutMinutes);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
service.stopCleanupTask();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 15, timeout: 3000 } // 减少运行次数并添加超时
|
|
||||||
);
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('模块生命周期', () => {
|
|
||||||
it('应该在模块初始化时启动清理任务', async () => {
|
|
||||||
// 重新创建服务实例来测试模块初始化
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
SessionCleanupService,
|
|
||||||
{
|
|
||||||
provide: SessionManagerService,
|
|
||||||
useValue: mockSessionManager,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
|
||||||
useValue: mockZulipClientPool,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
const newService = module.get<SessionCleanupService>(SessionCleanupService);
|
|
||||||
|
|
||||||
// 模拟模块初始化
|
|
||||||
await newService.onModuleInit();
|
|
||||||
|
|
||||||
const status = newService.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(true);
|
|
||||||
|
|
||||||
// 清理
|
|
||||||
await newService.onModuleDestroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在模块销毁时停止清理任务', async () => {
|
|
||||||
service.startCleanupTask();
|
|
||||||
|
|
||||||
await service.onModuleDestroy();
|
|
||||||
|
|
||||||
const status = service.getStatus();
|
|
||||||
expect(status.isEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
/**
|
|
||||||
* 会话清理定时任务服务
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 定时清理过期的游戏会话
|
|
||||||
* - 自动注销对应的Zulip事件队列
|
|
||||||
* - 释放系统资源
|
|
||||||
*
|
|
||||||
* 主要方法:
|
|
||||||
* - startCleanupTask(): 启动清理定时任务
|
|
||||||
* - stopCleanupTask(): 停止清理定时任务
|
|
||||||
* - runCleanup(): 执行一次清理
|
|
||||||
*
|
|
||||||
* 使用场景:
|
|
||||||
* - 系统启动时自动启动清理任务
|
|
||||||
* - 定期清理超时的会话数据
|
|
||||||
* - 释放Zulip事件队列资源
|
|
||||||
*
|
|
||||||
* @author angjustinl
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2025-12-25
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
|
|
||||||
import { SessionManagerService } from './session_manager.service';
|
|
||||||
import { IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理任务配置接口
|
|
||||||
*/
|
|
||||||
export interface CleanupConfig {
|
|
||||||
/** 清理间隔(毫秒),默认5分钟 */
|
|
||||||
intervalMs: number;
|
|
||||||
/** 会话超时时间(分钟),默认30分钟 */
|
|
||||||
sessionTimeoutMinutes: number;
|
|
||||||
/** 是否启用自动清理,默认true */
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理结果接口
|
|
||||||
*/
|
|
||||||
export interface CleanupResult {
|
|
||||||
/** 清理的会话数量 */
|
|
||||||
cleanedSessions: number;
|
|
||||||
/** 注销的Zulip队列数量 */
|
|
||||||
deregisteredQueues: number;
|
|
||||||
/** 清理耗时(毫秒) */
|
|
||||||
duration: number;
|
|
||||||
/** 清理时间 */
|
|
||||||
timestamp: Date;
|
|
||||||
/** 是否成功 */
|
|
||||||
success: boolean;
|
|
||||||
/** 错误信息(如果有) */
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话清理服务类
|
|
||||||
*
|
|
||||||
* 职责:
|
|
||||||
* - 定时清理过期的游戏会话
|
|
||||||
* - 释放无效的Zulip客户端资源
|
|
||||||
* - 维护会话数据的一致性
|
|
||||||
* - 提供会话清理统计和监控
|
|
||||||
*
|
|
||||||
* 主要方法:
|
|
||||||
* - startCleanup(): 启动定时清理任务
|
|
||||||
* - stopCleanup(): 停止清理任务
|
|
||||||
* - performCleanup(): 执行一次清理操作
|
|
||||||
* - getCleanupStats(): 获取清理统计信息
|
|
||||||
* - updateConfig(): 更新清理配置
|
|
||||||
*
|
|
||||||
* 使用场景:
|
|
||||||
* - 系统启动时自动开始清理任务
|
|
||||||
* - 定期清理过期会话和资源
|
|
||||||
* - 系统关闭时停止清理任务
|
|
||||||
* - 监控清理效果和系统健康
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SessionCleanupService implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
||||||
private isRunning = false;
|
|
||||||
private lastCleanupResult: CleanupResult | null = null;
|
|
||||||
private readonly logger = new Logger(SessionCleanupService.name);
|
|
||||||
|
|
||||||
private readonly config: CleanupConfig = {
|
|
||||||
intervalMs: 5 * 60 * 1000, // 5分钟
|
|
||||||
sessionTimeoutMinutes: 30, // 30分钟
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly sessionManager: SessionManagerService,
|
|
||||||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
|
||||||
private readonly zulipClientPool: IZulipClientPoolService,
|
|
||||||
) {
|
|
||||||
this.logger.log('SessionCleanupService初始化完成');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模块初始化时启动清理任务
|
|
||||||
*/
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
if (this.config.enabled) {
|
|
||||||
this.startCleanupTask();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模块销毁时停止清理任务
|
|
||||||
*/
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
this.stopCleanupTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动清理定时任务
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 启动定时任务,按配置的间隔定期清理过期会话
|
|
||||||
*/
|
|
||||||
startCleanupTask(): void {
|
|
||||||
if (this.cleanupInterval) {
|
|
||||||
this.logger.warn('清理任务已在运行中', {
|
|
||||||
operation: 'startCleanupTask',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log('启动会话清理定时任务', {
|
|
||||||
operation: 'startCleanupTask',
|
|
||||||
intervalMs: this.config.intervalMs,
|
|
||||||
sessionTimeoutMinutes: this.config.sessionTimeoutMinutes,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cleanupInterval = setInterval(async () => {
|
|
||||||
await this.runCleanup();
|
|
||||||
}, this.config.intervalMs);
|
|
||||||
|
|
||||||
// 立即执行一次清理
|
|
||||||
this.runCleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止清理定时任务
|
|
||||||
*/
|
|
||||||
stopCleanupTask(): void {
|
|
||||||
if (this.cleanupInterval) {
|
|
||||||
clearInterval(this.cleanupInterval);
|
|
||||||
this.cleanupInterval = null;
|
|
||||||
|
|
||||||
this.logger.log('停止会话清理定时任务', {
|
|
||||||
operation: 'stopCleanupTask',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前定时器引用(用于测试)
|
|
||||||
*/
|
|
||||||
getCleanupInterval(): NodeJS.Timeout | null {
|
|
||||||
return this.cleanupInterval;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行一次清理
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* 执行一次完整的清理流程:
|
|
||||||
* 1. 清理过期会话
|
|
||||||
* 2. 注销对应的Zulip事件队列
|
|
||||||
*
|
|
||||||
* @returns Promise<CleanupResult> 清理结果
|
|
||||||
*/
|
|
||||||
async runCleanup(): Promise<CleanupResult> {
|
|
||||||
if (this.isRunning) {
|
|
||||||
this.logger.warn('清理任务正在执行中,跳过本次执行', {
|
|
||||||
operation: 'runCleanup',
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
cleanedSessions: 0,
|
|
||||||
deregisteredQueues: 0,
|
|
||||||
duration: 0,
|
|
||||||
timestamp: new Date(),
|
|
||||||
success: false,
|
|
||||||
error: '清理任务正在执行中',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isRunning = true;
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
this.logger.log('开始执行会话清理', {
|
|
||||||
operation: 'runCleanup',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 清理过期会话
|
|
||||||
const cleanupResult = await this.sessionManager.cleanupExpiredSessions(
|
|
||||||
this.config.sessionTimeoutMinutes
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. 注销对应的Zulip事件队列
|
|
||||||
let deregisteredQueues = 0;
|
|
||||||
const queueIds = cleanupResult?.zulipQueueIds || [];
|
|
||||||
for (const queueId of queueIds) {
|
|
||||||
try {
|
|
||||||
// 根据queueId找到对应的用户并注销队列
|
|
||||||
// 注意:这里需要通过某种方式找到queueId对应的userId
|
|
||||||
// 由于会话已被清理,我们需要在清理前记录userId
|
|
||||||
// 这里简化处理,直接尝试注销
|
|
||||||
this.logger.debug('尝试注销Zulip队列', {
|
|
||||||
operation: 'runCleanup',
|
|
||||||
queueId,
|
|
||||||
});
|
|
||||||
deregisteredQueues++;
|
|
||||||
} catch (deregisterError) {
|
|
||||||
const err = deregisterError as Error;
|
|
||||||
this.logger.warn('注销Zulip队列失败', {
|
|
||||||
operation: 'runCleanup',
|
|
||||||
queueId,
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
const result: CleanupResult = {
|
|
||||||
cleanedSessions: cleanupResult?.cleanedCount || 0,
|
|
||||||
deregisteredQueues,
|
|
||||||
duration,
|
|
||||||
timestamp: new Date(),
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.lastCleanupResult = result;
|
|
||||||
|
|
||||||
this.logger.log('会话清理完成', {
|
|
||||||
operation: 'runCleanup',
|
|
||||||
cleanedSessions: result.cleanedSessions,
|
|
||||||
deregisteredQueues: result.deregisteredQueues,
|
|
||||||
duration,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
const result: CleanupResult = {
|
|
||||||
cleanedSessions: 0,
|
|
||||||
deregisteredQueues: 0,
|
|
||||||
duration,
|
|
||||||
timestamp: new Date(),
|
|
||||||
success: false,
|
|
||||||
error: err.message,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.lastCleanupResult = result;
|
|
||||||
|
|
||||||
this.logger.error('会话清理失败', {
|
|
||||||
operation: 'runCleanup',
|
|
||||||
error: err.message,
|
|
||||||
duration,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}, err.stack);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
this.isRunning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取最后一次清理结果
|
|
||||||
*
|
|
||||||
* @returns CleanupResult | null 最后一次清理结果
|
|
||||||
*/
|
|
||||||
getLastCleanupResult(): CleanupResult | null {
|
|
||||||
return this.lastCleanupResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取清理任务状态
|
|
||||||
*
|
|
||||||
* @returns 清理任务状态信息
|
|
||||||
*/
|
|
||||||
getStatus(): {
|
|
||||||
isRunning: boolean;
|
|
||||||
isEnabled: boolean;
|
|
||||||
config: CleanupConfig;
|
|
||||||
lastResult: CleanupResult | null;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
isRunning: this.isRunning,
|
|
||||||
isEnabled: this.cleanupInterval !== null,
|
|
||||||
config: this.config,
|
|
||||||
lastResult: this.lastCleanupResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新清理配置
|
|
||||||
*
|
|
||||||
* @param config 新的配置
|
|
||||||
*/
|
|
||||||
updateConfig(config: Partial<CleanupConfig>): void {
|
|
||||||
const wasEnabled = this.cleanupInterval !== null;
|
|
||||||
|
|
||||||
if (config.intervalMs !== undefined) {
|
|
||||||
this.config.intervalMs = config.intervalMs;
|
|
||||||
}
|
|
||||||
if (config.sessionTimeoutMinutes !== undefined) {
|
|
||||||
this.config.sessionTimeoutMinutes = config.sessionTimeoutMinutes;
|
|
||||||
}
|
|
||||||
if (config.enabled !== undefined) {
|
|
||||||
this.config.enabled = config.enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log('更新清理配置', {
|
|
||||||
operation: 'updateConfig',
|
|
||||||
config: this.config,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果配置改变,重启任务
|
|
||||||
if (wasEnabled) {
|
|
||||||
this.stopCleanupTask();
|
|
||||||
if (this.config.enabled) {
|
|
||||||
this.startCleanupTask();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
/**
|
|
||||||
* 会话管理服务测试
|
|
||||||
*
|
|
||||||
* 功能描述:
|
|
||||||
* - 测试SessionManagerService的核心功能
|
|
||||||
* - 包含属性测试验证会话状态一致性
|
|
||||||
*
|
|
||||||
* @author angjustinl
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2025-12-25
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import * as fc from 'fast-check';
|
|
||||||
import { SessionManagerService, GameSession, Position } from './session_manager.service';
|
|
||||||
import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces';
|
|
||||||
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
|
|
||||||
import { IRedisService } from '../../../core/redis/redis.interface';
|
|
||||||
|
|
||||||
describe('SessionManagerService', () => {
|
|
||||||
let service: SessionManagerService;
|
|
||||||
let mockLogger: jest.Mocked<AppLoggerService>;
|
|
||||||
let mockRedisService: jest.Mocked<IRedisService>;
|
|
||||||
let mockConfigManager: jest.Mocked<IZulipConfigService>;
|
|
||||||
|
|
||||||
// 内存存储模拟Redis
|
|
||||||
let memoryStore: Map<string, { value: string; expireAt?: number }>;
|
|
||||||
let memorySets: Map<string, Set<string>>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
// 初始化内存存储
|
|
||||||
memoryStore = new Map();
|
|
||||||
memorySets = new Map();
|
|
||||||
|
|
||||||
mockLogger = {
|
|
||||||
info: jest.fn(),
|
|
||||||
warn: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
debug: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
mockConfigManager = {
|
|
||||||
getStreamByMap: jest.fn().mockImplementation((mapId: string) => {
|
|
||||||
const streamMap: Record<string, string> = {
|
|
||||||
'whale_port': 'Whale Port',
|
|
||||||
'pumpkin_valley': 'Pumpkin Valley',
|
|
||||||
'offer_city': 'Offer City',
|
|
||||||
'model_factory': 'Model Factory',
|
|
||||||
'kernel_island': 'Kernel Island',
|
|
||||||
'moyu_beach': 'Moyu Beach',
|
|
||||||
'ladder_peak': 'Ladder Peak',
|
|
||||||
'galaxy_bay': 'Galaxy Bay',
|
|
||||||
'data_ruins': 'Data Ruins',
|
|
||||||
'novice_village': 'Novice Village',
|
|
||||||
};
|
|
||||||
return streamMap[mapId] || 'General';
|
|
||||||
}),
|
|
||||||
getMapIdByStream: jest.fn(),
|
|
||||||
getTopicByObject: jest.fn().mockReturnValue('General'),
|
|
||||||
findNearbyObject: jest.fn().mockReturnValue(null),
|
|
||||||
getZulipConfig: jest.fn(),
|
|
||||||
hasMap: jest.fn(),
|
|
||||||
hasStream: jest.fn(),
|
|
||||||
getAllMapIds: jest.fn(),
|
|
||||||
getAllStreams: jest.fn(),
|
|
||||||
reloadConfig: jest.fn(),
|
|
||||||
validateConfig: 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);
|
|
||||||
}),
|
|
||||||
expire: jest.fn().mockImplementation(async (key: string, ttl: number) => {
|
|
||||||
const item = memoryStore.get(key);
|
|
||||||
if (item) {
|
|
||||||
item.expireAt = Date.now() + ttl * 1000;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
ttl: jest.fn().mockResolvedValue(3600),
|
|
||||||
incr: jest.fn().mockResolvedValue(1),
|
|
||||||
sadd: jest.fn().mockImplementation(async (key: string, member: string) => {
|
|
||||||
if (!memorySets.has(key)) {
|
|
||||||
memorySets.set(key, new Set());
|
|
||||||
}
|
|
||||||
memorySets.get(key)!.add(member);
|
|
||||||
}),
|
|
||||||
srem: jest.fn().mockImplementation(async (key: string, member: string) => {
|
|
||||||
const set = memorySets.get(key);
|
|
||||||
if (set) {
|
|
||||||
set.delete(member);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
smembers: jest.fn().mockImplementation(async (key: string) => {
|
|
||||||
const set = memorySets.get(key);
|
|
||||||
return set ? Array.from(set) : [];
|
|
||||||
}),
|
|
||||||
flushall: jest.fn().mockImplementation(async () => {
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
}),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
SessionManagerService,
|
|
||||||
{
|
|
||||||
provide: AppLoggerService,
|
|
||||||
useValue: mockLogger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: 'REDIS_SERVICE',
|
|
||||||
useValue: mockRedisService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: 'ZULIP_CONFIG_SERVICE',
|
|
||||||
useValue: mockConfigManager,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<SessionManagerService>(SessionManagerService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
// 清理内存存储
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
|
|
||||||
// 等待任何正在进行的异步操作完成
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createSession - 创建会话', () => {
|
|
||||||
it('应该成功创建新会话', async () => {
|
|
||||||
const session = await service.createSession(
|
|
||||||
'socket-123',
|
|
||||||
'user-456',
|
|
||||||
'queue-789',
|
|
||||||
'TestUser',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(session).toBeDefined();
|
|
||||||
expect(session.socketId).toBe('socket-123');
|
|
||||||
expect(session.userId).toBe('user-456');
|
|
||||||
expect(session.zulipQueueId).toBe('queue-789');
|
|
||||||
expect(session.username).toBe('TestUser');
|
|
||||||
expect(session.currentMap).toBe('novice_village');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在socketId为空时抛出错误', async () => {
|
|
||||||
await expect(service.createSession('', 'user-456', 'queue-789'))
|
|
||||||
.rejects.toThrow('socketId不能为空');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在userId为空时抛出错误', async () => {
|
|
||||||
await expect(service.createSession('socket-123', '', 'queue-789'))
|
|
||||||
.rejects.toThrow('userId不能为空');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在zulipQueueId为空时抛出错误', async () => {
|
|
||||||
await expect(service.createSession('socket-123', 'user-456', ''))
|
|
||||||
.rejects.toThrow('zulipQueueId不能为空');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该清理用户已有的旧会话', async () => {
|
|
||||||
// 创建第一个会话
|
|
||||||
await service.createSession('socket-old', 'user-456', 'queue-old');
|
|
||||||
|
|
||||||
// 创建第二个会话(同一用户)
|
|
||||||
const newSession = await service.createSession('socket-new', 'user-456', 'queue-new');
|
|
||||||
|
|
||||||
expect(newSession.socketId).toBe('socket-new');
|
|
||||||
|
|
||||||
// 旧会话应该被清理
|
|
||||||
const oldSession = await service.getSession('socket-old');
|
|
||||||
expect(oldSession).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSession - 获取会话', () => {
|
|
||||||
it('应该返回已存在的会话', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
|
|
||||||
const session = await service.getSession('socket-123');
|
|
||||||
|
|
||||||
expect(session).toBeDefined();
|
|
||||||
expect(session?.socketId).toBe('socket-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在会话不存在时返回null', async () => {
|
|
||||||
const session = await service.getSession('nonexistent');
|
|
||||||
|
|
||||||
expect(session).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在socketId为空时返回null', async () => {
|
|
||||||
const session = await service.getSession('');
|
|
||||||
|
|
||||||
expect(session).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSessionByUserId - 根据用户ID获取会话', () => {
|
|
||||||
it('应该返回用户的会话', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
|
|
||||||
const session = await service.getSessionByUserId('user-456');
|
|
||||||
|
|
||||||
expect(session).toBeDefined();
|
|
||||||
expect(session?.userId).toBe('user-456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在用户没有会话时返回null', async () => {
|
|
||||||
const session = await service.getSessionByUserId('nonexistent');
|
|
||||||
|
|
||||||
expect(session).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updatePlayerPosition - 更新玩家位置', () => {
|
|
||||||
it('应该成功更新位置', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
|
|
||||||
const result = await service.updatePlayerPosition('socket-123', 'novice_village', 100, 200);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
const session = await service.getSession('socket-123');
|
|
||||||
expect(session?.position).toEqual({ x: 100, y: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在切换地图时更新地图玩家列表', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
|
|
||||||
const result = await service.updatePlayerPosition('socket-123', 'tavern', 150, 250);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
const session = await service.getSession('socket-123');
|
|
||||||
expect(session?.currentMap).toBe('tavern');
|
|
||||||
|
|
||||||
// 验证地图玩家列表更新
|
|
||||||
const tavernPlayers = await service.getSocketsInMap('tavern');
|
|
||||||
expect(tavernPlayers).toContain('socket-123');
|
|
||||||
|
|
||||||
const villagePlayers = await service.getSocketsInMap('novice_village');
|
|
||||||
expect(villagePlayers).not.toContain('socket-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在会话不存在时返回false', async () => {
|
|
||||||
const result = await service.updatePlayerPosition('nonexistent', 'tavern', 100, 200);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('destroySession - 销毁会话', () => {
|
|
||||||
it('应该成功销毁会话', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
|
|
||||||
const result = await service.destroySession('socket-123');
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
const session = await service.getSession('socket-123');
|
|
||||||
expect(session).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在会话不存在时返回true', async () => {
|
|
||||||
const result = await service.destroySession('nonexistent');
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该清理用户会话映射', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
await service.destroySession('socket-123');
|
|
||||||
|
|
||||||
const session = await service.getSessionByUserId('user-456');
|
|
||||||
expect(session).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getSocketsInMap - 获取地图玩家列表', () => {
|
|
||||||
it('应该返回地图中的所有玩家', async () => {
|
|
||||||
await service.createSession('socket-1', 'user-1', 'queue-1');
|
|
||||||
await service.createSession('socket-2', 'user-2', 'queue-2');
|
|
||||||
|
|
||||||
const sockets = await service.getSocketsInMap('novice_village');
|
|
||||||
|
|
||||||
expect(sockets).toHaveLength(2);
|
|
||||||
expect(sockets).toContain('socket-1');
|
|
||||||
expect(sockets).toContain('socket-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在地图为空时返回空数组', async () => {
|
|
||||||
const sockets = await service.getSocketsInMap('empty_map');
|
|
||||||
|
|
||||||
expect(sockets).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('injectContext - 上下文注入', () => {
|
|
||||||
it('应该返回正确的Stream', async () => {
|
|
||||||
await service.createSession('socket-123', 'user-456', 'queue-789');
|
|
||||||
|
|
||||||
const context = await service.injectContext('socket-123');
|
|
||||||
|
|
||||||
expect(context.stream).toBe('Novice Village');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在会话不存在时返回默认上下文', async () => {
|
|
||||||
const context = await service.injectContext('nonexistent');
|
|
||||||
|
|
||||||
expect(context.stream).toBe('General');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性测试: 会话状态一致性
|
|
||||||
*
|
|
||||||
* **Feature: zulip-integration, Property 6: 会话状态一致性**
|
|
||||||
* **Validates: Requirements 6.1, 6.2, 6.3, 6.5**
|
|
||||||
*
|
|
||||||
* 对于任何玩家会话,系统应该在Redis中正确维护WebSocket ID与Zulip队列ID的映射关系,
|
|
||||||
* 及时更新位置信息,并支持服务重启后的状态恢复
|
|
||||||
*/
|
|
||||||
describe('Property 6: 会话状态一致性', () => {
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何有效的会话参数,创建会话后应该能够正确获取
|
|
||||||
* 验证需求 6.1: 玩家登录成功后系统应在Redis中存储WebSocket ID与Zulip队列ID的映射关系
|
|
||||||
*/
|
|
||||||
it('对于任何有效的会话参数,创建会话后应该能够正确获取', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成有效的socketId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的userId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的zulipQueueId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的username
|
|
||||||
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
|
||||||
async (socketId, userId, zulipQueueId, username) => {
|
|
||||||
// 清理之前的数据
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
|
|
||||||
// 创建会话
|
|
||||||
const createdSession = await service.createSession(
|
|
||||||
socketId.trim(),
|
|
||||||
userId.trim(),
|
|
||||||
zulipQueueId.trim(),
|
|
||||||
username.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证创建的会话
|
|
||||||
expect(createdSession.socketId).toBe(socketId.trim());
|
|
||||||
expect(createdSession.userId).toBe(userId.trim());
|
|
||||||
expect(createdSession.zulipQueueId).toBe(zulipQueueId.trim());
|
|
||||||
expect(createdSession.username).toBe(username.trim());
|
|
||||||
|
|
||||||
// 获取会话并验证一致性
|
|
||||||
const retrievedSession = await service.getSession(socketId.trim());
|
|
||||||
expect(retrievedSession).not.toBeNull();
|
|
||||||
expect(retrievedSession?.socketId).toBe(createdSession.socketId);
|
|
||||||
expect(retrievedSession?.userId).toBe(createdSession.userId);
|
|
||||||
expect(retrievedSession?.zulipQueueId).toBe(createdSession.zulipQueueId);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 50, timeout: 5000 } // 添加超时控制
|
|
||||||
);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何位置更新,会话应该正确反映新位置
|
|
||||||
* 验证需求 6.2: 玩家切换地图时系统应更新玩家的当前位置信息
|
|
||||||
*/
|
|
||||||
it('对于任何位置更新,会话应该正确反映新位置', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成有效的socketId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的userId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的地图ID
|
|
||||||
fc.constantFrom('novice_village', 'tavern', 'market'),
|
|
||||||
// 生成有效的坐标
|
|
||||||
fc.integer({ min: 0, max: 1000 }),
|
|
||||||
fc.integer({ min: 0, max: 1000 }),
|
|
||||||
async (socketId, userId, mapId, x, y) => {
|
|
||||||
// 清理之前的数据
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
|
|
||||||
// 创建会话
|
|
||||||
await service.createSession(
|
|
||||||
socketId.trim(),
|
|
||||||
userId.trim(),
|
|
||||||
'queue-test',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新位置
|
|
||||||
const updateResult = await service.updatePlayerPosition(
|
|
||||||
socketId.trim(),
|
|
||||||
mapId,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(updateResult).toBe(true);
|
|
||||||
|
|
||||||
// 验证位置更新
|
|
||||||
const session = await service.getSession(socketId.trim());
|
|
||||||
expect(session).not.toBeNull();
|
|
||||||
expect(session?.currentMap).toBe(mapId);
|
|
||||||
expect(session?.position.x).toBe(x);
|
|
||||||
expect(session?.position.y).toBe(y);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 50, timeout: 5000 } // 添加超时控制
|
|
||||||
);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何地图切换,玩家应该从旧地图移除并添加到新地图
|
|
||||||
* 验证需求 6.3: 查询在线玩家时系统应从Redis中获取当前活跃的会话列表
|
|
||||||
*/
|
|
||||||
it('对于任何地图切换,玩家应该从旧地图移除并添加到新地图', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成有效的socketId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的userId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成初始地图和目标地图(确保不同)
|
|
||||||
fc.constantFrom('novice_village', 'tavern', 'market'),
|
|
||||||
fc.constantFrom('novice_village', 'tavern', 'market'),
|
|
||||||
async (socketId, userId, initialMap, targetMap) => {
|
|
||||||
// 清理之前的数据
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
|
|
||||||
// 创建会话(使用初始地图)
|
|
||||||
await service.createSession(
|
|
||||||
socketId.trim(),
|
|
||||||
userId.trim(),
|
|
||||||
'queue-test',
|
|
||||||
'TestUser',
|
|
||||||
initialMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证初始地图包含玩家
|
|
||||||
const initialPlayers = await service.getSocketsInMap(initialMap);
|
|
||||||
expect(initialPlayers).toContain(socketId.trim());
|
|
||||||
|
|
||||||
// 如果目标地图不同,切换地图
|
|
||||||
if (initialMap !== targetMap) {
|
|
||||||
await service.updatePlayerPosition(socketId.trim(), targetMap, 100, 100);
|
|
||||||
|
|
||||||
// 验证旧地图不再包含玩家
|
|
||||||
const oldMapPlayers = await service.getSocketsInMap(initialMap);
|
|
||||||
expect(oldMapPlayers).not.toContain(socketId.trim());
|
|
||||||
|
|
||||||
// 验证新地图包含玩家
|
|
||||||
const newMapPlayers = await service.getSocketsInMap(targetMap);
|
|
||||||
expect(newMapPlayers).toContain(socketId.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 50, timeout: 5000 } // 添加超时控制
|
|
||||||
);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何会话销毁,所有相关数据应该被清理
|
|
||||||
* 验证需求 6.5: 服务器重启时系统应能够从Redis中恢复会话状态(通过验证销毁后数据被正确清理)
|
|
||||||
*/
|
|
||||||
it('对于任何会话销毁,所有相关数据应该被清理', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成有效的socketId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的userId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的地图ID
|
|
||||||
fc.constantFrom('novice_village', 'tavern', 'market'),
|
|
||||||
async (socketId, userId, mapId) => {
|
|
||||||
// 清理之前的数据
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
|
|
||||||
// 创建会话
|
|
||||||
await service.createSession(
|
|
||||||
socketId.trim(),
|
|
||||||
userId.trim(),
|
|
||||||
'queue-test',
|
|
||||||
'TestUser',
|
|
||||||
mapId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证会话存在
|
|
||||||
const sessionBefore = await service.getSession(socketId.trim());
|
|
||||||
expect(sessionBefore).not.toBeNull();
|
|
||||||
|
|
||||||
// 销毁会话
|
|
||||||
const destroyResult = await service.destroySession(socketId.trim());
|
|
||||||
expect(destroyResult).toBe(true);
|
|
||||||
|
|
||||||
// 验证会话被清理
|
|
||||||
const sessionAfter = await service.getSession(socketId.trim());
|
|
||||||
expect(sessionAfter).toBeNull();
|
|
||||||
|
|
||||||
// 验证用户会话映射被清理
|
|
||||||
const userSession = await service.getSessionByUserId(userId.trim());
|
|
||||||
expect(userSession).toBeNull();
|
|
||||||
|
|
||||||
// 验证地图玩家列表被清理
|
|
||||||
const mapPlayers = await service.getSocketsInMap(mapId);
|
|
||||||
expect(mapPlayers).not.toContain(socketId.trim());
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 50, timeout: 5000 } // 添加超时控制
|
|
||||||
);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 创建-更新-销毁的完整生命周期应该正确管理会话状态
|
|
||||||
*/
|
|
||||||
it('创建-更新-销毁的完整生命周期应该正确管理会话状态', async () => {
|
|
||||||
await fc.assert(
|
|
||||||
fc.asyncProperty(
|
|
||||||
// 生成有效的socketId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成有效的userId
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
||||||
// 生成位置更新序列
|
|
||||||
fc.array(
|
|
||||||
fc.record({
|
|
||||||
mapId: fc.constantFrom('novice_village', 'tavern', 'market'),
|
|
||||||
x: fc.integer({ min: 0, max: 1000 }),
|
|
||||||
y: fc.integer({ min: 0, max: 1000 }),
|
|
||||||
}),
|
|
||||||
{ minLength: 1, maxLength: 5 }
|
|
||||||
),
|
|
||||||
async (socketId, userId, positionUpdates) => {
|
|
||||||
// 清理之前的数据
|
|
||||||
memoryStore.clear();
|
|
||||||
memorySets.clear();
|
|
||||||
|
|
||||||
// 1. 创建会话
|
|
||||||
const session = await service.createSession(
|
|
||||||
socketId.trim(),
|
|
||||||
userId.trim(),
|
|
||||||
'queue-test',
|
|
||||||
);
|
|
||||||
expect(session).toBeDefined();
|
|
||||||
|
|
||||||
// 2. 执行位置更新序列
|
|
||||||
for (const update of positionUpdates) {
|
|
||||||
const result = await service.updatePlayerPosition(
|
|
||||||
socketId.trim(),
|
|
||||||
update.mapId,
|
|
||||||
update.x,
|
|
||||||
update.y,
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
// 验证每次更新后的状态
|
|
||||||
const currentSession = await service.getSession(socketId.trim());
|
|
||||||
expect(currentSession?.currentMap).toBe(update.mapId);
|
|
||||||
expect(currentSession?.position.x).toBe(update.x);
|
|
||||||
expect(currentSession?.position.y).toBe(update.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 销毁会话
|
|
||||||
const destroyResult = await service.destroySession(socketId.trim());
|
|
||||||
expect(destroyResult).toBe(true);
|
|
||||||
|
|
||||||
// 4. 验证所有数据被清理
|
|
||||||
const finalSession = await service.getSession(socketId.trim());
|
|
||||||
expect(finalSession).toBeNull();
|
|
||||||
}
|
|
||||||
),
|
|
||||||
{ numRuns: 50, timeout: 5000 } // 添加超时控制
|
|
||||||
);
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,13 @@
|
|||||||
* **Feature: zulip-integration, Property 5: 消息接收和分发**
|
* **Feature: zulip-integration, Property 5: 消息接收和分发**
|
||||||
* **Validates: Requirements 5.1, 5.2, 5.5**
|
* **Validates: Requirements 5.1, 5.2, 5.5**
|
||||||
*
|
*
|
||||||
|
* 更新记录:
|
||||||
|
* - 2026-01-14: 重构后更新 - 使用 ISessionQueryService 接口替代具体实现
|
||||||
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.0.0
|
* @version 2.0.0
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
@@ -25,14 +29,19 @@ import {
|
|||||||
GameMessage,
|
GameMessage,
|
||||||
MessageDistributor,
|
MessageDistributor,
|
||||||
} from './zulip_event_processor.service';
|
} from './zulip_event_processor.service';
|
||||||
import { SessionManagerService, GameSession } from './session_manager.service';
|
import {
|
||||||
|
ISessionQueryService,
|
||||||
|
IGameSession,
|
||||||
|
SESSION_QUERY_SERVICE,
|
||||||
|
} from '../../../core/session_core/session_core.interfaces';
|
||||||
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces';
|
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||||||
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
|
|
||||||
|
// 为测试定义 GameSession 类型别名
|
||||||
|
type GameSession = IGameSession;
|
||||||
|
|
||||||
describe('ZulipEventProcessorService', () => {
|
describe('ZulipEventProcessorService', () => {
|
||||||
let service: ZulipEventProcessorService;
|
let service: ZulipEventProcessorService;
|
||||||
let mockLogger: jest.Mocked<AppLoggerService>;
|
let mockSessionManager: jest.Mocked<ISessionQueryService>;
|
||||||
let mockSessionManager: jest.Mocked<SessionManagerService>;
|
|
||||||
let mockConfigManager: jest.Mocked<IZulipConfigService>;
|
let mockConfigManager: jest.Mocked<IZulipConfigService>;
|
||||||
let mockClientPool: jest.Mocked<IZulipClientPoolService>;
|
let mockClientPool: jest.Mocked<IZulipClientPoolService>;
|
||||||
let mockDistributor: jest.Mocked<MessageDistributor>;
|
let mockDistributor: jest.Mocked<MessageDistributor>;
|
||||||
@@ -67,20 +76,9 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
mockLogger = {
|
|
||||||
info: jest.fn(),
|
|
||||||
warn: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
debug: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
mockSessionManager = {
|
mockSessionManager = {
|
||||||
getSession: jest.fn(),
|
getSession: jest.fn(),
|
||||||
getSocketsInMap: jest.fn(),
|
getSocketsInMap: jest.fn(),
|
||||||
createSession: jest.fn(),
|
|
||||||
destroySession: jest.fn(),
|
|
||||||
updatePlayerPosition: jest.fn(),
|
|
||||||
injectContext: jest.fn(),
|
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
mockConfigManager = {
|
mockConfigManager = {
|
||||||
@@ -117,11 +115,7 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
ZulipEventProcessorService,
|
ZulipEventProcessorService,
|
||||||
{
|
{
|
||||||
provide: AppLoggerService,
|
provide: SESSION_QUERY_SERVICE,
|
||||||
useValue: mockLogger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: SessionManagerService,
|
|
||||||
useValue: mockSessionManager,
|
useValue: mockSessionManager,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -197,30 +191,18 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 属性测试: 消息格式转换完整性
|
* 属性测试: 消息格式转换完整性
|
||||||
*
|
*
|
||||||
* **Feature: zulip-integration, Property 4: 消息格式转换完整性**
|
* **Feature: zulip-integration, Property 4: 消息格式转换完整性**
|
||||||
* **Validates: Requirements 5.3, 5.4**
|
* **Validates: Requirements 5.3, 5.4**
|
||||||
*
|
|
||||||
* 对于任何在Zulip和游戏之间转发的消息,转换后的消息应该包含所有必需的信息
|
|
||||||
* (发送者、内容、时间戳),并符合目标协议格式
|
|
||||||
*/
|
*/
|
||||||
describe('Property 4: 消息格式转换完整性', () => {
|
describe('Property 4: 消息格式转换完整性', () => {
|
||||||
/**
|
|
||||||
* 属性: 对于任何有效的Zulip消息,转换后应该包含发送者信息
|
|
||||||
* 验证需求 5.3: 确定目标玩家时系统应将消息转换为游戏协议格式
|
|
||||||
* 验证需求 5.4: 转换消息格式时系统应包含发送者信息、消息内容和时间戳
|
|
||||||
*/
|
|
||||||
it('对于任何有效的Zulip消息,转换后应该包含发送者信息', async () => {
|
it('对于任何有效的Zulip消息,转换后应该包含发送者信息', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成有效的发送者全名
|
|
||||||
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||||
// 生成有效的发送者邮箱
|
|
||||||
fc.emailAddress(),
|
fc.emailAddress(),
|
||||||
// 生成有效的消息内容
|
|
||||||
fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0),
|
||||||
async (senderName, senderEmail, content) => {
|
async (senderName, senderEmail, content) => {
|
||||||
const zulipMessage = createMockZulipMessage({
|
const zulipMessage = createMockZulipMessage({
|
||||||
@@ -231,14 +213,9 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
|
|
||||||
const result = await service.convertMessageFormat(zulipMessage);
|
const result = await service.convertMessageFormat(zulipMessage);
|
||||||
|
|
||||||
// 验证消息类型正确
|
|
||||||
expect(result.t).toBe('chat_render');
|
expect(result.t).toBe('chat_render');
|
||||||
|
|
||||||
// 验证发送者信息存在且非空
|
|
||||||
expect(result.from).toBeDefined();
|
expect(result.from).toBeDefined();
|
||||||
expect(result.from.length).toBeGreaterThan(0);
|
expect(result.from.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 验证发送者名称正确(应该是senderName或从邮箱提取)
|
|
||||||
expect(result.from).toBe(senderName.trim());
|
expect(result.from).toBe(senderName.trim());
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -246,31 +223,23 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何sender_full_name为空的消息,应该从邮箱提取用户名
|
|
||||||
* 验证需求 5.4: 转换消息格式时系统应包含发送者信息
|
|
||||||
*/
|
|
||||||
it('对于任何sender_full_name为空的消息,应该从邮箱提取用户名', async () => {
|
it('对于任何sender_full_name为空的消息,应该从邮箱提取用户名', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成有效的邮箱用户名部分
|
|
||||||
fc.string({ minLength: 1, maxLength: 30 })
|
fc.string({ minLength: 1, maxLength: 30 })
|
||||||
.filter(s => s.trim().length > 0 && /^[a-zA-Z0-9._-]+$/.test(s)),
|
.filter(s => s.trim().length > 0 && /^[a-zA-Z0-9._-]+$/.test(s)),
|
||||||
// 生成有效的域名
|
|
||||||
fc.constantFrom('example.com', 'test.org', 'mail.net'),
|
fc.constantFrom('example.com', 'test.org', 'mail.net'),
|
||||||
// 生成有效的消息内容
|
|
||||||
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
||||||
async (username, domain, content) => {
|
async (username, domain, content) => {
|
||||||
const email = `${username}@${domain}`;
|
const email = `${username}@${domain}`;
|
||||||
const zulipMessage = createMockZulipMessage({
|
const zulipMessage = createMockZulipMessage({
|
||||||
sender_full_name: '', // 空的全名
|
sender_full_name: '',
|
||||||
sender_email: email,
|
sender_email: email,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.convertMessageFormat(zulipMessage);
|
const result = await service.convertMessageFormat(zulipMessage);
|
||||||
|
|
||||||
// 验证从邮箱提取了用户名
|
|
||||||
expect(result.from).toBe(username);
|
expect(result.from).toBe(username);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -278,18 +247,12 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何消息内容,转换后应该保留核心文本信息
|
|
||||||
* 验证需求 5.4: 转换消息格式时系统应包含消息内容
|
|
||||||
*/
|
|
||||||
it('对于任何消息内容,转换后应该保留核心文本信息', async () => {
|
it('对于任何消息内容,转换后应该保留核心文本信息', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成纯文本消息内容(不含Markdown和HTML标记)
|
|
||||||
fc.string({ minLength: 1, maxLength: 150 })
|
fc.string({ minLength: 1, maxLength: 150 })
|
||||||
.filter(s => {
|
.filter(s => {
|
||||||
const trimmed = s.trim();
|
const trimmed = s.trim();
|
||||||
// 排除Markdown标记和HTML标记
|
|
||||||
return trimmed.length > 0 &&
|
return trimmed.length > 0 &&
|
||||||
!/[*_`#\[\]<>]/.test(trimmed) &&
|
!/[*_`#\[\]<>]/.test(trimmed) &&
|
||||||
!trimmed.startsWith('>') &&
|
!trimmed.startsWith('>') &&
|
||||||
@@ -304,11 +267,9 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
|
|
||||||
const result = await service.convertMessageFormat(zulipMessage);
|
const result = await service.convertMessageFormat(zulipMessage);
|
||||||
|
|
||||||
// 验证消息内容存在
|
|
||||||
expect(result.txt).toBeDefined();
|
expect(result.txt).toBeDefined();
|
||||||
expect(result.txt.length).toBeGreaterThan(0);
|
expect(result.txt.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 验证核心内容被保留(对于短消息应该完全匹配)
|
|
||||||
if (content.trim().length <= 200) {
|
if (content.trim().length <= 200) {
|
||||||
expect(result.txt).toBe(content.trim());
|
expect(result.txt).toBe(content.trim());
|
||||||
}
|
}
|
||||||
@@ -318,14 +279,9 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何超过200字符的消息,应该被截断并添加省略号
|
|
||||||
* 验证需求 5.4: 转换消息格式时系统应正确处理消息内容
|
|
||||||
*/
|
|
||||||
it('对于任何超过200字符的消息,应该被截断并添加省略号', async () => {
|
it('对于任何超过200字符的消息,应该被截断并添加省略号', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成超过200字符的纯字母数字消息内容(避免Markdown/HTML标记影响长度)
|
|
||||||
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '.split('')), { minLength: 250, maxLength: 500 })
|
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '.split('')), { minLength: 250, maxLength: 500 })
|
||||||
.map(arr => arr.join('')),
|
.map(arr => arr.join('')),
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
@@ -335,10 +291,7 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
|
|
||||||
const result = await service.convertMessageFormat(zulipMessage);
|
const result = await service.convertMessageFormat(zulipMessage);
|
||||||
|
|
||||||
// 验证消息被截断
|
|
||||||
expect(result.txt.length).toBeLessThanOrEqual(200);
|
expect(result.txt.length).toBeLessThanOrEqual(200);
|
||||||
|
|
||||||
// 验证添加了省略号
|
|
||||||
expect(result.txt.endsWith('...')).toBe(true);
|
expect(result.txt.endsWith('...')).toBe(true);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -346,21 +299,14 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何包含Markdown的消息,应该正确移除格式标记
|
|
||||||
* 验证需求 5.4: 转换消息格式时系统应正确处理消息内容
|
|
||||||
* 注意: 列表标记(- + *)会被转换为bullet point(•),这是预期行为,不在此测试范围
|
|
||||||
*/
|
|
||||||
it('对于任何包含Markdown的消息,应该正确移除格式标记', async () => {
|
it('对于任何包含Markdown的消息,应该正确移除格式标记', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成纯字母数字基础文本(避免特殊字符干扰)
|
|
||||||
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('')), { minLength: 1, maxLength: 50 })
|
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('')), { minLength: 1, maxLength: 50 })
|
||||||
.map(arr => arr.join('')),
|
.map(arr => arr.join('')),
|
||||||
// 选择Markdown格式类型(仅测试inline格式,不测试列表)
|
|
||||||
fc.constantFrom('bold', 'italic', 'code', 'link'),
|
fc.constantFrom('bold', 'italic', 'code', 'link'),
|
||||||
async (text: string, formatType: string) => {
|
async (text: string, formatType: string) => {
|
||||||
if (text.length === 0) return; // 跳过空字符串
|
if (text.length === 0) return;
|
||||||
|
|
||||||
let markdownContent: string;
|
let markdownContent: string;
|
||||||
|
|
||||||
@@ -369,7 +315,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
markdownContent = `**${text}**`;
|
markdownContent = `**${text}**`;
|
||||||
break;
|
break;
|
||||||
case 'italic':
|
case 'italic':
|
||||||
// 使用下划线斜体避免与列表标记冲突
|
|
||||||
markdownContent = `_${text}_`;
|
markdownContent = `_${text}_`;
|
||||||
break;
|
break;
|
||||||
case 'code':
|
case 'code':
|
||||||
@@ -388,7 +333,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
|
|
||||||
const result = await service.convertMessageFormat(zulipMessage);
|
const result = await service.convertMessageFormat(zulipMessage);
|
||||||
|
|
||||||
// 验证Markdown标记被移除,只保留文本
|
|
||||||
expect(result.txt).toBe(text);
|
expect(result.txt).toBe(text);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -396,14 +340,9 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何消息,转换结果应该符合游戏协议格式
|
|
||||||
* 验证需求 5.3: 确定目标玩家时系统应将消息转换为游戏协议格式
|
|
||||||
*/
|
|
||||||
it('对于任何消息,转换结果应该符合游戏协议格式', async () => {
|
it('对于任何消息,转换结果应该符合游戏协议格式', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成随机的Zulip消息属性
|
|
||||||
fc.record({
|
fc.record({
|
||||||
sender_full_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
sender_full_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||||
sender_email: fc.emailAddress(),
|
sender_email: fc.emailAddress(),
|
||||||
@@ -422,19 +361,15 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
|
|
||||||
const result = await service.convertMessageFormat(zulipMessage);
|
const result = await service.convertMessageFormat(zulipMessage);
|
||||||
|
|
||||||
// 验证游戏协议格式
|
|
||||||
expect(result).toHaveProperty('t', 'chat_render');
|
expect(result).toHaveProperty('t', 'chat_render');
|
||||||
expect(result).toHaveProperty('from');
|
expect(result).toHaveProperty('from');
|
||||||
expect(result).toHaveProperty('txt');
|
expect(result).toHaveProperty('txt');
|
||||||
expect(result).toHaveProperty('bubble');
|
expect(result).toHaveProperty('bubble');
|
||||||
|
|
||||||
// 验证类型正确
|
|
||||||
expect(typeof result.t).toBe('string');
|
expect(typeof result.t).toBe('string');
|
||||||
expect(typeof result.from).toBe('string');
|
expect(typeof result.from).toBe('string');
|
||||||
expect(typeof result.txt).toBe('string');
|
expect(typeof result.txt).toBe('string');
|
||||||
expect(typeof result.bubble).toBe('boolean');
|
expect(typeof result.bubble).toBe('boolean');
|
||||||
|
|
||||||
// 验证bubble默认为true
|
|
||||||
expect(result.bubble).toBe(true);
|
expect(result.bubble).toBe(true);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -443,7 +378,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('determineTargetPlayers - 确定目标玩家', () => {
|
describe('determineTargetPlayers - 确定目标玩家', () => {
|
||||||
it('应该根据Stream名称确定目标地图并获取玩家列表', async () => {
|
it('应该根据Stream名称确定目标地图并获取玩家列表', async () => {
|
||||||
const zulipMessage = createMockZulipMessage({
|
const zulipMessage = createMockZulipMessage({
|
||||||
@@ -476,14 +410,13 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']);
|
mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']);
|
||||||
mockSessionManager.getSession.mockImplementation(async (socketId) => {
|
mockSessionManager.getSession.mockImplementation(async (socketId) => {
|
||||||
if (socketId === 'socket-1') {
|
if (socketId === 'socket-1') {
|
||||||
return createMockSession({ socketId: 'socket-1', userId: 'sender-user' }); // 发送者
|
return createMockSession({ socketId: 'socket-1', userId: 'sender-user' });
|
||||||
}
|
}
|
||||||
return createMockSession({ socketId: 'socket-2', userId: 'other-user' });
|
return createMockSession({ socketId: 'socket-2', userId: 'other-user' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.determineTargetPlayers(zulipMessage, 'Tavern', 'sender-user');
|
const result = await service.determineTargetPlayers(zulipMessage, 'Tavern', 'sender-user');
|
||||||
|
|
||||||
// 发送者应该被排除
|
|
||||||
expect(result).not.toContain('socket-1');
|
expect(result).not.toContain('socket-1');
|
||||||
expect(result).toContain('socket-2');
|
expect(result).toContain('socket-2');
|
||||||
});
|
});
|
||||||
@@ -538,24 +471,13 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
*
|
*
|
||||||
* **Feature: zulip-integration, Property 5: 消息接收和分发**
|
* **Feature: zulip-integration, Property 5: 消息接收和分发**
|
||||||
* **Validates: Requirements 5.1, 5.2, 5.5**
|
* **Validates: Requirements 5.1, 5.2, 5.5**
|
||||||
*
|
|
||||||
* 对于任何从Zulip接收的消息,系统应该正确确定目标玩家,转换消息格式,
|
|
||||||
* 并通过WebSocket发送给所有相关的游戏客户端
|
|
||||||
*/
|
*/
|
||||||
describe('Property 5: 消息接收和分发', () => {
|
describe('Property 5: 消息接收和分发', () => {
|
||||||
/**
|
|
||||||
* 属性: 对于任何有效的Stream消息,应该正确确定目标地图
|
|
||||||
* 验证需求 5.1: Zulip中有新消息时系统应通过事件队列接收消息通知
|
|
||||||
* 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息
|
|
||||||
*/
|
|
||||||
it('对于任何有效的Stream消息,应该正确确定目标地图', async () => {
|
it('对于任何有效的Stream消息,应该正确确定目标地图', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成有效的Stream名称
|
|
||||||
fc.constantFrom('Tavern', 'Novice Village', 'Market', 'General'),
|
fc.constantFrom('Tavern', 'Novice Village', 'Market', 'General'),
|
||||||
// 生成对应的地图ID
|
|
||||||
fc.constantFrom('tavern', 'novice_village', 'market', 'general'),
|
fc.constantFrom('tavern', 'novice_village', 'market', 'general'),
|
||||||
// 生成玩家Socket ID列表
|
|
||||||
fc.array(
|
fc.array(
|
||||||
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
{ minLength: 0, maxLength: 10 }
|
{ minLength: 0, maxLength: 10 }
|
||||||
@@ -565,7 +487,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
display_recipient: streamName,
|
display_recipient: streamName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 设置模拟返回值
|
|
||||||
mockConfigManager.getMapIdByStream.mockReturnValue(mapId);
|
mockConfigManager.getMapIdByStream.mockReturnValue(mapId);
|
||||||
mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds);
|
mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds);
|
||||||
mockSessionManager.getSession.mockImplementation(async (socketId) => {
|
mockSessionManager.getSession.mockImplementation(async (socketId) => {
|
||||||
@@ -582,14 +503,12 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
'different-sender'
|
'different-sender'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证调用了正确的方法
|
|
||||||
expect(mockConfigManager.getMapIdByStream).toHaveBeenCalledWith(streamName);
|
expect(mockConfigManager.getMapIdByStream).toHaveBeenCalledWith(streamName);
|
||||||
|
|
||||||
if (socketIds.length > 0) {
|
if (socketIds.length > 0) {
|
||||||
expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId);
|
expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证返回的Socket ID数量正确(所有玩家都不是发送者)
|
|
||||||
expect(result.length).toBe(socketIds.length);
|
expect(result.length).toBe(socketIds.length);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -597,16 +516,10 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何消息分发,发送者应该被排除在接收者之外
|
|
||||||
* 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息
|
|
||||||
*/
|
|
||||||
it('对于任何消息分发,发送者应该被排除在接收者之外', async () => {
|
it('对于任何消息分发,发送者应该被排除在接收者之外', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成发送者用户ID
|
|
||||||
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
// 生成其他玩家用户ID列表
|
|
||||||
fc.array(
|
fc.array(
|
||||||
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
{ minLength: 1, maxLength: 5 }
|
{ minLength: 1, maxLength: 5 }
|
||||||
@@ -616,7 +529,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
display_recipient: 'Tavern',
|
display_recipient: 'Tavern',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建包含发送者的Socket列表
|
|
||||||
const allSocketIds = [`socket_${senderUserId}`, ...otherUserIds.map(id => `socket_${id}`)];
|
const allSocketIds = [`socket_${senderUserId}`, ...otherUserIds.map(id => `socket_${id}`)];
|
||||||
|
|
||||||
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
|
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
|
||||||
@@ -635,10 +547,8 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
senderUserId
|
senderUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证发送者被排除
|
|
||||||
expect(result).not.toContain(`socket_${senderUserId}`);
|
expect(result).not.toContain(`socket_${senderUserId}`);
|
||||||
|
|
||||||
// 验证其他玩家都在结果中
|
|
||||||
for (const userId of otherUserIds) {
|
for (const userId of otherUserIds) {
|
||||||
expect(result).toContain(`socket_${userId}`);
|
expect(result).toContain(`socket_${userId}`);
|
||||||
}
|
}
|
||||||
@@ -648,18 +558,11 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何消息分发,所有目标玩家都应该收到消息
|
|
||||||
* 验证需求 5.5: 推送消息到游戏客户端时系统应通过WebSocket发送消息
|
|
||||||
*/
|
|
||||||
it('对于任何消息分发,所有目标玩家都应该收到消息', async () => {
|
it('对于任何消息分发,所有目标玩家都应该收到消息', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成发送者名称
|
|
||||||
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||||
// 生成消息内容
|
|
||||||
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
||||||
// 生成目标玩家Socket ID列表
|
|
||||||
fc.array(
|
fc.array(
|
||||||
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
{ minLength: 1, maxLength: 10 }
|
{ minLength: 1, maxLength: 10 }
|
||||||
@@ -672,12 +575,10 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
bubble: true,
|
bubble: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置mock
|
|
||||||
mockDistributor.sendChatRender.mockClear();
|
mockDistributor.sendChatRender.mockClear();
|
||||||
|
|
||||||
await service.distributeMessage(gameMessage, targetPlayers);
|
await service.distributeMessage(gameMessage, targetPlayers);
|
||||||
|
|
||||||
// 验证每个目标玩家都收到了消息
|
|
||||||
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(targetPlayers.length);
|
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(targetPlayers.length);
|
||||||
|
|
||||||
for (const socketId of targetPlayers) {
|
for (const socketId of targetPlayers) {
|
||||||
@@ -694,14 +595,9 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 对于任何未知Stream的消息,应该返回空的目标玩家列表
|
|
||||||
* 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息
|
|
||||||
*/
|
|
||||||
it('对于任何未知Stream的消息,应该返回空的目标玩家列表', async () => {
|
it('对于任何未知Stream的消息,应该返回空的目标玩家列表', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成未知的Stream名称
|
|
||||||
fc.string({ minLength: 5, maxLength: 50 })
|
fc.string({ minLength: 5, maxLength: 50 })
|
||||||
.filter(s => s.trim().length > 0)
|
.filter(s => s.trim().length > 0)
|
||||||
.map(s => `Unknown_${s}`),
|
.map(s => `Unknown_${s}`),
|
||||||
@@ -710,7 +606,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
display_recipient: unknownStream,
|
display_recipient: unknownStream,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 模拟未找到对应地图
|
|
||||||
mockConfigManager.getMapIdByStream.mockReturnValue(null);
|
mockConfigManager.getMapIdByStream.mockReturnValue(null);
|
||||||
|
|
||||||
const result = await service.determineTargetPlayers(
|
const result = await service.determineTargetPlayers(
|
||||||
@@ -719,10 +614,7 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
'sender-user'
|
'sender-user'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 验证返回空列表
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
// 验证没有尝试获取玩家列表
|
|
||||||
expect(mockSessionManager.getSocketsInMap).not.toHaveBeenCalled();
|
expect(mockSessionManager.getSocketsInMap).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -730,24 +622,16 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
);
|
);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性: 完整的消息处理流程应该正确执行
|
|
||||||
* 验证需求 5.1, 5.2, 5.5: 完整的消息接收和分发流程
|
|
||||||
*/
|
|
||||||
it('完整的消息处理流程应该正确执行', async () => {
|
it('完整的消息处理流程应该正确执行', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc.asyncProperty(
|
fc.asyncProperty(
|
||||||
// 生成发送者信息
|
|
||||||
fc.record({
|
fc.record({
|
||||||
senderName: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
senderName: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||||
senderEmail: fc.emailAddress(),
|
senderEmail: fc.emailAddress(),
|
||||||
senderUserId: fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
senderUserId: fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
}),
|
}),
|
||||||
// 生成消息内容
|
|
||||||
fc.string({ minLength: 1, maxLength: 150 }).filter(s => s.trim().length > 0),
|
fc.string({ minLength: 1, maxLength: 150 }).filter(s => s.trim().length > 0),
|
||||||
// 生成Stream名称
|
|
||||||
fc.constantFrom('Tavern', 'Novice Village'),
|
fc.constantFrom('Tavern', 'Novice Village'),
|
||||||
// 生成目标玩家数量
|
|
||||||
fc.integer({ min: 1, max: 5 }),
|
fc.integer({ min: 1, max: 5 }),
|
||||||
async (sender, content, streamName, playerCount) => {
|
async (sender, content, streamName, playerCount) => {
|
||||||
const zulipMessage = createMockZulipMessage({
|
const zulipMessage = createMockZulipMessage({
|
||||||
@@ -757,7 +641,6 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
display_recipient: streamName,
|
display_recipient: streamName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成目标玩家
|
|
||||||
const targetSocketIds = Array.from(
|
const targetSocketIds = Array.from(
|
||||||
{ length: playerCount },
|
{ length: playerCount },
|
||||||
(_, i) => `socket_player_${i}`
|
(_, i) => `socket_player_${i}`
|
||||||
@@ -774,17 +657,12 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 重置mock
|
|
||||||
mockDistributor.sendChatRender.mockClear();
|
mockDistributor.sendChatRender.mockClear();
|
||||||
|
|
||||||
// 执行完整的消息处理
|
|
||||||
const result = await service.processMessageManually(zulipMessage, sender.senderUserId);
|
const result = await service.processMessageManually(zulipMessage, sender.senderUserId);
|
||||||
|
|
||||||
// 验证处理成功
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.targetCount).toBe(playerCount);
|
expect(result.targetCount).toBe(playerCount);
|
||||||
|
|
||||||
// 验证消息被分发给所有目标玩家
|
|
||||||
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(playerCount);
|
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(playerCount);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -811,14 +689,12 @@ describe('ZulipEventProcessorService', () => {
|
|||||||
const queueId = 'test-queue-123';
|
const queueId = 'test-queue-123';
|
||||||
const userId = 'user-456';
|
const userId = 'user-456';
|
||||||
|
|
||||||
// 注册队列
|
|
||||||
await service.registerEventQueue(queueId, userId, 0);
|
await service.registerEventQueue(queueId, userId, 0);
|
||||||
|
|
||||||
let stats = service.getProcessingStats();
|
let stats = service.getProcessingStats();
|
||||||
expect(stats.queueIds).toContain(queueId);
|
expect(stats.queueIds).toContain(queueId);
|
||||||
expect(stats.totalQueues).toBe(1);
|
expect(stats.totalQueues).toBe(1);
|
||||||
|
|
||||||
// 注销队列
|
|
||||||
await service.unregisterEventQueue(queueId);
|
await service.unregisterEventQueue(queueId);
|
||||||
|
|
||||||
stats = service.getProcessingStats();
|
stats = service.getProcessingStats();
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
* - 实现空间过滤和消息分发
|
* - 实现空间过滤和消息分发
|
||||||
* - 支持区域广播功能
|
* - 支持区域广播功能
|
||||||
*
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - 事件轮询:管理Zulip事件队列的轮询和处理
|
||||||
|
* - 消息转换:将Zulip消息转换为游戏协议格式
|
||||||
|
* - 空间过滤:根据地图确定消息接收者
|
||||||
|
* - 消息分发:通过WebSocket向目标玩家发送消息
|
||||||
|
*
|
||||||
* 主要方法:
|
* 主要方法:
|
||||||
* - startEventProcessing(): 启动事件处理循环
|
* - startEventProcessing(): 启动事件处理循环
|
||||||
* - processMessageEvent(): 处理Zulip消息事件
|
* - processMessageEvent(): 处理Zulip消息事件
|
||||||
@@ -20,18 +26,27 @@
|
|||||||
* - 向游戏客户端分发消息
|
* - 向游戏客户端分发消息
|
||||||
*
|
*
|
||||||
* 依赖模块:
|
* 依赖模块:
|
||||||
* - SessionManagerService: 会话管理服务
|
* - ISessionQueryService: 会话查询接口(通过 Core 层接口解耦)
|
||||||
* - ConfigManagerService: 配置管理服务
|
* - ConfigManagerService: 配置管理服务
|
||||||
* - ZulipClientPoolService: Zulip客户端池服务
|
* - ZulipClientPoolService: Zulip客户端池服务
|
||||||
* - AppLoggerService: 日志记录服务
|
* - AppLoggerService: 日志记录服务
|
||||||
*
|
*
|
||||||
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 代码质量优化 - 移除未使用的IGameSession导入 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin)
|
||||||
|
* - 2025-12-25: 功能新增 - 初始创建Zulip事件处理服务 (修改者: angjustinl)
|
||||||
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.0.0
|
* @version 1.1.2
|
||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, OnModuleDestroy, Inject, forwardRef, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, Inject, Logger } from '@nestjs/common';
|
||||||
import { SessionManagerService } from './session_manager.service';
|
import {
|
||||||
|
ISessionQueryService,
|
||||||
|
SESSION_QUERY_SERVICE,
|
||||||
|
} from '../../../core/session_core/session_core.interfaces';
|
||||||
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces';
|
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,7 +144,8 @@ export class ZulipEventProcessorService implements OnModuleDestroy {
|
|||||||
private readonly MAX_EVENTS_PER_POLL = 100;
|
private readonly MAX_EVENTS_PER_POLL = 100;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sessionManager: SessionManagerService,
|
@Inject(SESSION_QUERY_SERVICE)
|
||||||
|
private readonly sessionManager: ISessionQueryService,
|
||||||
@Inject('ZULIP_CONFIG_SERVICE')
|
@Inject('ZULIP_CONFIG_SERVICE')
|
||||||
private readonly configManager: IZulipConfigService,
|
private readonly configManager: IZulipConfigService,
|
||||||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
||||||
|
|||||||
@@ -4,37 +4,32 @@
|
|||||||
* 功能描述:
|
* 功能描述:
|
||||||
* - 测试模块配置的正确性
|
* - 测试模块配置的正确性
|
||||||
* - 验证依赖注入配置的完整性
|
* - 验证依赖注入配置的完整性
|
||||||
* - 测试服务和控制器的注册
|
* - 测试服务的注册
|
||||||
* - 验证模块导出的正确性
|
* - 验证模块导出的正确性
|
||||||
*
|
*
|
||||||
* 测试范围:
|
* 测试范围:
|
||||||
* - 模块导入配置验证
|
* - 模块导入配置验证
|
||||||
* - 服务提供者注册验证
|
* - 服务提供者注册验证
|
||||||
* - 控制器注册验证
|
|
||||||
* - 模块导出验证
|
* - 模块导出验证
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 架构说明:
|
||||||
* - 2026-01-12: 代码规范优化 - 创建测试文件,确保模块配置逻辑的测试覆盖 (修改者: moyin)
|
* - Business层:仅包含业务逻辑服务
|
||||||
|
* - Controller已迁移到Gateway层(src/gateway/zulip/)
|
||||||
|
*
|
||||||
|
* 更新记录:
|
||||||
|
* - 2026-01-14: 架构优化 - Controller迁移到Gateway层,更新测试用例 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 重构后更新 - 聊天功能已迁移到 gateway/chat 和 business/chat 模块
|
||||||
|
* 本模块仅保留 Zulip 账号管理和事件处理功能
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 3.0.0
|
||||||
* @since 2026-01-12
|
* @since 2026-01-12
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ZulipModule } from './zulip.module';
|
import { ZulipModule } from './zulip.module';
|
||||||
import { ZulipService } from './zulip.service';
|
|
||||||
import { SessionManagerService } from './services/session_manager.service';
|
|
||||||
import { MessageFilterService } from './services/message_filter.service';
|
|
||||||
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
||||||
import { SessionCleanupService } from './services/session_cleanup.service';
|
import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service';
|
||||||
import { CleanWebSocketGateway } from './clean_websocket.gateway';
|
|
||||||
import { ChatController } from './chat.controller';
|
|
||||||
import { WebSocketDocsController } from './websocket_docs.controller';
|
|
||||||
import { WebSocketOpenApiController } from './websocket_openapi.controller';
|
|
||||||
import { ZulipAccountsController } from './zulip_accounts.controller';
|
|
||||||
import { WebSocketTestController } from './websocket_test.controller';
|
|
||||||
import { DynamicConfigController } from './dynamic_config.controller';
|
|
||||||
import { DynamicConfigManagerService } from '../../core/zulip_core/services/dynamic_config_manager.service';
|
import { DynamicConfigManagerService } from '../../core/zulip_core/services/dynamic_config_manager.service';
|
||||||
|
|
||||||
describe('ZulipModule', () => {
|
describe('ZulipModule', () => {
|
||||||
@@ -50,85 +45,42 @@ describe('ZulipModule', () => {
|
|||||||
const exportsMetadata = Reflect.getMetadata('exports', ZulipModule) || [];
|
const exportsMetadata = Reflect.getMetadata('exports', ZulipModule) || [];
|
||||||
|
|
||||||
// 验证导入的模块数量
|
// 验证导入的模块数量
|
||||||
expect(moduleMetadata).toHaveLength(6);
|
expect(moduleMetadata.length).toBeGreaterThanOrEqual(6);
|
||||||
|
|
||||||
// 验证提供者数量
|
// 验证提供者数量(2个业务服务)
|
||||||
expect(providersMetadata).toHaveLength(7);
|
expect(providersMetadata).toHaveLength(2);
|
||||||
|
|
||||||
// 验证控制器数量
|
// 验证控制器数量(Controller已迁移到Gateway层,应为0)
|
||||||
expect(controllersMetadata).toHaveLength(6);
|
expect(controllersMetadata).toHaveLength(0);
|
||||||
|
|
||||||
// 验证导出数量
|
// 验证导出数量(3个服务:2个业务服务 + 1个重新导出的Core服务)
|
||||||
expect(exportsMetadata).toHaveLength(7);
|
expect(exportsMetadata).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Service Providers', () => {
|
describe('Service Providers', () => {
|
||||||
it('should include ZulipService in providers', () => {
|
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
|
||||||
expect(providers).toContain(ZulipService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include SessionManagerService in providers', () => {
|
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
|
||||||
expect(providers).toContain(SessionManagerService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include MessageFilterService in providers', () => {
|
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
|
||||||
expect(providers).toContain(MessageFilterService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include ZulipEventProcessorService in providers', () => {
|
it('should include ZulipEventProcessorService in providers', () => {
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
expect(providers).toContain(ZulipEventProcessorService);
|
expect(providers).toContain(ZulipEventProcessorService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include SessionCleanupService in providers', () => {
|
it('should include ZulipAccountsBusinessService in providers', () => {
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
expect(providers).toContain(SessionCleanupService);
|
expect(providers).toContain(ZulipAccountsBusinessService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include CleanWebSocketGateway in providers', () => {
|
it('should NOT include DynamicConfigManagerService in providers (provided by ZulipCoreModule)', () => {
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
expect(providers).toContain(CleanWebSocketGateway);
|
// DynamicConfigManagerService 由 ZulipCoreModule 提供,不在本模块的 providers 中
|
||||||
});
|
expect(providers).not.toContain(DynamicConfigManagerService);
|
||||||
|
|
||||||
it('should include DynamicConfigManagerService in providers', () => {
|
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
|
||||||
expect(providers).toContain(DynamicConfigManagerService);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Controllers', () => {
|
describe('Controllers (Migrated to Gateway Layer)', () => {
|
||||||
it('should include ChatController in controllers', () => {
|
it('should NOT have any controllers (migrated to src/gateway/zulip/)', () => {
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
||||||
expect(controllers).toContain(ChatController);
|
// 所有Controller已迁移到Gateway层
|
||||||
});
|
expect(controllers).toHaveLength(0);
|
||||||
|
|
||||||
it('should include WebSocketDocsController in controllers', () => {
|
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
|
||||||
expect(controllers).toContain(WebSocketDocsController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include WebSocketOpenApiController in controllers', () => {
|
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
|
||||||
expect(controllers).toContain(WebSocketOpenApiController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include ZulipAccountsController in controllers', () => {
|
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
|
||||||
expect(controllers).toContain(ZulipAccountsController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include WebSocketTestController in controllers', () => {
|
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
|
||||||
expect(controllers).toContain(WebSocketTestController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include DynamicConfigController in controllers', () => {
|
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
|
||||||
expect(controllers).toContain(DynamicConfigController);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,20 +112,15 @@ describe('ZulipModule', () => {
|
|||||||
it('should have proper service dependencies', () => {
|
it('should have proper service dependencies', () => {
|
||||||
// 验证服务依赖关系
|
// 验证服务依赖关系
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
expect(providers).toContain(ZulipService);
|
expect(providers).toContain(ZulipEventProcessorService);
|
||||||
expect(providers).toContain(SessionManagerService);
|
expect(providers).toContain(ZulipAccountsBusinessService);
|
||||||
expect(providers).toContain(MessageFilterService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should export essential services', () => {
|
it('should export essential services', () => {
|
||||||
// 验证导出的服务
|
// 验证导出的服务
|
||||||
const exports = Reflect.getMetadata('exports', ZulipModule) || [];
|
const exports = Reflect.getMetadata('exports', ZulipModule) || [];
|
||||||
expect(exports).toContain(ZulipService);
|
|
||||||
expect(exports).toContain(SessionManagerService);
|
|
||||||
expect(exports).toContain(MessageFilterService);
|
|
||||||
expect(exports).toContain(ZulipEventProcessorService);
|
expect(exports).toContain(ZulipEventProcessorService);
|
||||||
expect(exports).toContain(SessionCleanupService);
|
expect(exports).toContain(ZulipAccountsBusinessService);
|
||||||
expect(exports).toContain(CleanWebSocketGateway);
|
|
||||||
expect(exports).toContain(DynamicConfigManagerService);
|
expect(exports).toContain(DynamicConfigManagerService);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -193,8 +140,8 @@ describe('ZulipModule', () => {
|
|||||||
it('should have all required imports', () => {
|
it('should have all required imports', () => {
|
||||||
const imports = Reflect.getMetadata('imports', ZulipModule) || [];
|
const imports = Reflect.getMetadata('imports', ZulipModule) || [];
|
||||||
|
|
||||||
// 验证必需的模块导入
|
// 验证必需的模块导入(至少6个)
|
||||||
expect(imports.length).toBe(6);
|
expect(imports.length).toBeGreaterThanOrEqual(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required providers', () => {
|
it('should have all required providers', () => {
|
||||||
@@ -202,13 +149,8 @@ describe('ZulipModule', () => {
|
|||||||
|
|
||||||
// 验证所有必需的服务提供者
|
// 验证所有必需的服务提供者
|
||||||
const requiredProviders = [
|
const requiredProviders = [
|
||||||
ZulipService,
|
|
||||||
SessionManagerService,
|
|
||||||
MessageFilterService,
|
|
||||||
ZulipEventProcessorService,
|
ZulipEventProcessorService,
|
||||||
SessionCleanupService,
|
ZulipAccountsBusinessService,
|
||||||
CleanWebSocketGateway,
|
|
||||||
DynamicConfigManagerService,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
requiredProviders.forEach(provider => {
|
requiredProviders.forEach(provider => {
|
||||||
@@ -216,22 +158,11 @@ describe('ZulipModule', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required controllers', () => {
|
it('should have no controllers (Business layer)', () => {
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
||||||
|
|
||||||
// 验证所有必需的控制器
|
// Business层不应该包含Controller
|
||||||
const requiredControllers = [
|
expect(controllers).toHaveLength(0);
|
||||||
ChatController,
|
|
||||||
WebSocketDocsController,
|
|
||||||
WebSocketOpenApiController,
|
|
||||||
ZulipAccountsController,
|
|
||||||
WebSocketTestController,
|
|
||||||
DynamicConfigController,
|
|
||||||
];
|
|
||||||
|
|
||||||
requiredControllers.forEach(controller => {
|
|
||||||
expect(controllers).toContain(controller);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,31 +172,67 @@ describe('ZulipModule', () => {
|
|||||||
|
|
||||||
// 验证导入模块的数量和类型
|
// 验证导入模块的数量和类型
|
||||||
expect(Array.isArray(imports)).toBe(true);
|
expect(Array.isArray(imports)).toBe(true);
|
||||||
expect(imports.length).toBe(6);
|
expect(imports.length).toBeGreaterThanOrEqual(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct providers configuration', () => {
|
it('should have correct providers configuration', () => {
|
||||||
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
|
|
||||||
// 验证提供者的数量和类型
|
// 验证提供者的数量和类型(2个业务服务)
|
||||||
expect(Array.isArray(providers)).toBe(true);
|
expect(Array.isArray(providers)).toBe(true);
|
||||||
expect(providers.length).toBe(7);
|
expect(providers).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct controllers configuration', () => {
|
it('should have correct controllers configuration (empty for Business layer)', () => {
|
||||||
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
||||||
|
|
||||||
// 验证控制器的数量和类型
|
// Business层不包含Controller
|
||||||
expect(Array.isArray(controllers)).toBe(true);
|
expect(Array.isArray(controllers)).toBe(true);
|
||||||
expect(controllers.length).toBe(6);
|
expect(controllers).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct exports configuration', () => {
|
it('should have correct exports configuration', () => {
|
||||||
const exports = Reflect.getMetadata('exports', ZulipModule) || [];
|
const exports = Reflect.getMetadata('exports', ZulipModule) || [];
|
||||||
|
|
||||||
// 验证导出的数量和类型
|
// 验证导出的数量和类型(3个服务)
|
||||||
expect(Array.isArray(exports)).toBe(true);
|
expect(Array.isArray(exports)).toBe(true);
|
||||||
expect(exports.length).toBe(7);
|
expect(exports).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
describe('Architecture Compliance', () => {
|
||||||
|
it('should not include chat-related services (migrated to business/chat)', () => {
|
||||||
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
|
const providerNames = providers.map((p: any) => p.name || p.toString());
|
||||||
|
|
||||||
|
// 验证聊天相关服务已迁移
|
||||||
|
expect(providerNames).not.toContain('ZulipService');
|
||||||
|
expect(providerNames).not.toContain('SessionManagerService');
|
||||||
|
expect(providerNames).not.toContain('MessageFilterService');
|
||||||
|
expect(providerNames).not.toContain('SessionCleanupService');
|
||||||
|
expect(providerNames).not.toContain('CleanWebSocketGateway');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include any controllers (migrated to gateway/zulip)', () => {
|
||||||
|
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
||||||
|
|
||||||
|
// 验证所有Controller已迁移到Gateway层
|
||||||
|
expect(controllers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should follow four-layer architecture (Business layer has no controllers)', () => {
|
||||||
|
const controllers = Reflect.getMetadata('controllers', ZulipModule) || [];
|
||||||
|
const providers = Reflect.getMetadata('providers', ZulipModule) || [];
|
||||||
|
|
||||||
|
// Business层规范:只有Service,没有Controller
|
||||||
|
expect(controllers).toHaveLength(0);
|
||||||
|
expect(providers.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 验证所有provider都是Service类型
|
||||||
|
const providerNames = providers.map((p: any) => p.name || '');
|
||||||
|
providerNames.forEach((name: string) => {
|
||||||
|
expect(name).toMatch(/Service$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,128 +2,79 @@
|
|||||||
* Zulip集成业务模块
|
* Zulip集成业务模块
|
||||||
*
|
*
|
||||||
* 功能描述:
|
* 功能描述:
|
||||||
* - 整合Zulip集成相关的业务逻辑和控制器
|
* - 提供Zulip账号关联管理业务逻辑
|
||||||
* - 提供完整的Zulip集成业务功能模块
|
* - 提供Zulip事件处理业务逻辑
|
||||||
* - 实现游戏与Zulip的业务逻辑协调
|
* - 通过 SESSION_QUERY_SERVICE 接口与 ChatModule 解耦
|
||||||
* - 支持WebSocket网关、会话管理、消息过滤等业务功能
|
|
||||||
*
|
*
|
||||||
* 架构设计:
|
* 架构说明:
|
||||||
* - 业务逻辑层:处理游戏相关的业务规则和流程
|
* - Business层:专注业务逻辑处理,不包含HTTP协议处理
|
||||||
* - 核心服务层:封装技术实现细节和第三方API调用
|
* - Controller已迁移到Gateway层(src/gateway/zulip/)
|
||||||
* - 通过依赖注入实现业务层与技术层的解耦
|
* - 通过 Core 层接口解耦,不直接依赖其他模块的具体实现
|
||||||
*
|
*
|
||||||
* 业务服务:
|
* 迁移记录:
|
||||||
* - ZulipService: 主协调服务,处理登录、消息发送等核心业务流程
|
* - 2026-01-14: 架构优化 - 将所有Controller迁移到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
* - CleanWebSocketGateway: WebSocket统一网关,处理客户端连接
|
* - 2026-01-14: 架构优化 - 移除冗余的DynamicConfigManagerService声明,该服务已由ZulipCoreModule提供 (修改者: moyin)
|
||||||
* - SessionManagerService: 会话状态管理和业务逻辑
|
* - 2026-01-14: 聊天功能迁移到新的四层架构模块
|
||||||
* - MessageFilterService: 消息过滤和业务规则控制
|
* - CleanWebSocketGateway -> gateway/chat/chat.gateway.ts
|
||||||
*
|
* - ZulipService(聊天部分) -> business/chat/chat.service.ts
|
||||||
* 核心服务(通过ZulipCoreModule提供):
|
* - SessionManagerService -> business/chat/services/chat_session.service.ts
|
||||||
* - ZulipClientService: Zulip REST API封装
|
* - MessageFilterService -> business/chat/services/chat_filter.service.ts
|
||||||
* - ZulipClientPoolService: 客户端池管理
|
* - SessionCleanupService -> business/chat/services/chat_cleanup.service.ts
|
||||||
* - ConfigManagerService: 配置管理和热重载
|
* - ChatController -> gateway/chat/chat.controller.ts
|
||||||
* - ZulipEventProcessorService: 事件处理和消息转换
|
* - 2026-01-14: 通过 Core 层接口解耦,不再直接依赖 ChatModule 的具体实现
|
||||||
* - 其他技术支持服务
|
|
||||||
*
|
|
||||||
* 依赖模块:
|
|
||||||
* - ZulipCoreModule: Zulip核心技术服务
|
|
||||||
* - LoginCoreModule: 用户认证和会话管理
|
|
||||||
* - RedisModule: 会话状态缓存
|
|
||||||
* - LoggerModule: 日志记录服务
|
|
||||||
*
|
|
||||||
* 使用场景:
|
|
||||||
* - 游戏客户端通过WebSocket连接进行实时聊天
|
|
||||||
* - 游戏内消息与Zulip社群的双向同步
|
|
||||||
* - 基于位置的聊天上下文管理
|
|
||||||
* - 业务规则驱动的消息过滤和权限控制
|
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.1.0
|
* @version 3.0.0
|
||||||
* @since 2026-01-06
|
* @since 2026-01-06
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CleanWebSocketGateway } from './clean_websocket.gateway';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import { ZulipService } from './zulip.service';
|
// 业务服务
|
||||||
import { SessionManagerService } from './services/session_manager.service';
|
|
||||||
import { MessageFilterService } from './services/message_filter.service';
|
|
||||||
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
||||||
import { SessionCleanupService } from './services/session_cleanup.service';
|
|
||||||
import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service';
|
import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service';
|
||||||
import { ChatController } from './chat.controller';
|
// 依赖模块
|
||||||
import { WebSocketDocsController } from './websocket_docs.controller';
|
|
||||||
import { WebSocketOpenApiController } from './websocket_openapi.controller';
|
|
||||||
import { ZulipAccountsController } from './zulip_accounts.controller';
|
|
||||||
import { WebSocketTestController } from './websocket_test.controller';
|
|
||||||
import { DynamicConfigController } from './dynamic_config.controller';
|
|
||||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
|
||||||
import { RedisModule } from '../../core/redis/redis.module';
|
import { RedisModule } from '../../core/redis/redis.module';
|
||||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { DynamicConfigManagerService } from '../../core/zulip_core/services/dynamic_config_manager.service';
|
// 通过接口依赖 ChatModule(解耦)
|
||||||
|
import { ChatModule } from '../chat/chat.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// Zulip核心服务模块 - 提供技术实现相关的核心服务
|
// 缓存模块
|
||||||
|
CacheModule.register(),
|
||||||
|
// Zulip核心服务模块
|
||||||
ZulipCoreModule,
|
ZulipCoreModule,
|
||||||
// Zulip账号关联模块 - 提供账号关联管理功能
|
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||||
ZulipAccountsModule.forRoot(),
|
// Redis模块
|
||||||
// Redis模块 - 提供会话状态缓存和数据存储
|
|
||||||
RedisModule,
|
RedisModule,
|
||||||
// 日志模块 - 提供统一的日志记录服务
|
// 日志模块
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
// 登录模块 - 提供用户认证和Token验证
|
// 登录模块
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
// 认证模块 - 提供JWT验证和用户认证服务
|
// 认证模块
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
// 聊天模块 - 通过 SESSION_QUERY_SERVICE 接口提供会话查询能力
|
||||||
|
// ZulipEventProcessorService 依赖接口而非具体实现,实现解耦
|
||||||
|
ChatModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// 主协调服务 - 整合各子服务,提供统一业务接口
|
|
||||||
ZulipService,
|
|
||||||
// 会话管理服务 - 维护Socket_ID与Zulip_Queue_ID的映射关系
|
|
||||||
SessionManagerService,
|
|
||||||
// 消息过滤服务 - 敏感词过滤、频率限制、权限验证
|
|
||||||
MessageFilterService,
|
|
||||||
// Zulip事件处理服务 - 处理Zulip事件队列消息
|
// Zulip事件处理服务 - 处理Zulip事件队列消息
|
||||||
ZulipEventProcessorService,
|
ZulipEventProcessorService,
|
||||||
// 会话清理服务 - 定时清理过期会话
|
// Zulip账号业务服务 - 账号关联管理
|
||||||
SessionCleanupService,
|
ZulipAccountsBusinessService,
|
||||||
// WebSocket网关 - 处理游戏客户端WebSocket连接
|
|
||||||
CleanWebSocketGateway,
|
|
||||||
// 动态配置管理服务 - 从Zulip服务器动态获取配置
|
|
||||||
DynamicConfigManagerService,
|
|
||||||
],
|
|
||||||
controllers: [
|
|
||||||
// 聊天相关的REST API控制器
|
|
||||||
ChatController,
|
|
||||||
// WebSocket API文档控制器
|
|
||||||
WebSocketDocsController,
|
|
||||||
// WebSocket OpenAPI规范控制器
|
|
||||||
WebSocketOpenApiController,
|
|
||||||
// Zulip账号关联管理控制器
|
|
||||||
ZulipAccountsController,
|
|
||||||
// WebSocket测试工具控制器 - 提供测试页面和API监控
|
|
||||||
WebSocketTestController,
|
|
||||||
// 动态配置管理控制器 - 提供配置管理API
|
|
||||||
DynamicConfigController,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
// 导出主服务供其他模块使用
|
|
||||||
ZulipService,
|
|
||||||
// 导出会话管理服务
|
|
||||||
SessionManagerService,
|
|
||||||
// 导出消息过滤服务
|
|
||||||
MessageFilterService,
|
|
||||||
// 导出事件处理服务
|
// 导出事件处理服务
|
||||||
ZulipEventProcessorService,
|
ZulipEventProcessorService,
|
||||||
// 导出会话清理服务
|
// 导出账号业务服务
|
||||||
SessionCleanupService,
|
ZulipAccountsBusinessService,
|
||||||
// 导出WebSocket网关
|
// 重新导出ZulipCoreModule(包含DynamicConfigManagerService)
|
||||||
CleanWebSocketGateway,
|
ZulipCoreModule,
|
||||||
// 导出动态配置管理服务
|
|
||||||
DynamicConfigManagerService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ZulipModule {}
|
export class ZulipModule {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,14 @@
|
|||||||
* - 搜索优化:搜索异常的特殊处理机制
|
* - 搜索优化:搜索异常的特殊处理机制
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 为保护方法补充@example示例 (修改者: moyin)
|
||||||
* - 2026-01-07: 代码规范优化 - 完善注释规范,添加完整的文件头和方法注释
|
* - 2026-01-07: 代码规范优化 - 完善注释规范,添加完整的文件头和方法注释
|
||||||
* - 2026-01-07: 功能新增 - 添加敏感信息脱敏处理和结构化日志记录
|
* - 2026-01-07: 功能新增 - 添加敏感信息脱敏处理和结构化日志记录
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.1
|
* @version 1.0.2
|
||||||
* @since 2025-01-07
|
* @since 2025-01-07
|
||||||
* @lastModified 2026-01-07
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Logger, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Logger, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
@@ -33,6 +34,12 @@ export abstract class BaseUsersService {
|
|||||||
*
|
*
|
||||||
* @param error 原始错误对象
|
* @param error 原始错误对象
|
||||||
* @returns 格式化后的错误信息字符串
|
* @returns 格式化后的错误信息字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const errorMsg = this.formatError(new Error('数据库连接失败'));
|
||||||
|
* // 返回: "数据库连接失败"
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
protected formatError(error: unknown): string {
|
protected formatError(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -48,6 +55,15 @@ export abstract class BaseUsersService {
|
|||||||
* @param operation 操作名称
|
* @param operation 操作名称
|
||||||
* @param context 上下文信息
|
* @param context 上下文信息
|
||||||
* @throws 处理后的标准异常
|
* @throws 处理后的标准异常
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* // 业务操作
|
||||||
|
* } catch (error) {
|
||||||
|
* this.handleServiceError(error, '创建用户', { username: 'test' });
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
protected handleServiceError(error: unknown, operation: string, context?: Record<string, any>): never {
|
protected handleServiceError(error: unknown, operation: string, context?: Record<string, any>): never {
|
||||||
const errorMessage = this.formatError(error);
|
const errorMessage = this.formatError(error);
|
||||||
@@ -78,6 +94,15 @@ export abstract class BaseUsersService {
|
|||||||
* @param operation 操作名称
|
* @param operation 操作名称
|
||||||
* @param context 上下文信息
|
* @param context 上下文信息
|
||||||
* @returns 空数组
|
* @returns 空数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* try {
|
||||||
|
* // 搜索操作
|
||||||
|
* } catch (error) {
|
||||||
|
* return this.handleSearchError(error, '搜索用户', { keyword: 'test' });
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
protected handleSearchError(error: unknown, operation: string, context?: Record<string, any>): any[] {
|
protected handleSearchError(error: unknown, operation: string, context?: Record<string, any>): any[] {
|
||||||
const errorMessage = this.formatError(error);
|
const errorMessage = this.formatError(error);
|
||||||
@@ -98,6 +123,11 @@ export abstract class BaseUsersService {
|
|||||||
* @param operation 操作名称
|
* @param operation 操作名称
|
||||||
* @param context 上下文信息
|
* @param context 上下文信息
|
||||||
* @param duration 操作耗时
|
* @param duration 操作耗时
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* this.logSuccess('创建用户', { userId: '123', username: 'test' }, 50);
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
protected logSuccess(operation: string, context?: Record<string, any>, duration?: number): void {
|
protected logSuccess(operation: string, context?: Record<string, any>, duration?: number): void {
|
||||||
this.logger.log(`${operation}成功`, {
|
this.logger.log(`${operation}成功`, {
|
||||||
@@ -113,6 +143,11 @@ export abstract class BaseUsersService {
|
|||||||
*
|
*
|
||||||
* @param operation 操作名称
|
* @param operation 操作名称
|
||||||
* @param context 上下文信息
|
* @param context 上下文信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* this.logStart('创建用户', { username: 'test' });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
protected logStart(operation: string, context?: Record<string, any>): void {
|
protected logStart(operation: string, context?: Record<string, any>): void {
|
||||||
this.logger.log(`开始${operation}`, {
|
this.logger.log(`开始${operation}`, {
|
||||||
@@ -127,6 +162,16 @@ export abstract class BaseUsersService {
|
|||||||
*
|
*
|
||||||
* @param data 原始数据
|
* @param data 原始数据
|
||||||
* @returns 脱敏后的数据
|
* @returns 脱敏后的数据
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const sanitized = this.sanitizeLogData({
|
||||||
|
* email: 'test@example.com',
|
||||||
|
* phone: '13800138000',
|
||||||
|
* password_hash: 'secret'
|
||||||
|
* });
|
||||||
|
* // 返回: { email: 'te***@example.com', phone: '138****00', password_hash: '[REDACTED]' }
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
protected sanitizeLogData(data: Record<string, any>): Record<string, any> {
|
protected sanitizeLogData(data: Record<string, any>): Record<string, any> {
|
||||||
const sanitized = { ...data };
|
const sanitized = { ...data };
|
||||||
|
|||||||
@@ -6,13 +6,19 @@
|
|||||||
* - 避免魔法数字,提高代码可维护性
|
* - 避免魔法数字,提高代码可维护性
|
||||||
* - 集中管理配置参数
|
* - 集中管理配置参数
|
||||||
*
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - 常量定义:用户角色、字段限制、查询限制等常量值
|
||||||
|
* - 错误消息:统一的错误消息定义和管理
|
||||||
|
* - 工具类:性能监控和验证工具的封装
|
||||||
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 补充职责分离描述 (修改者: moyin)
|
||||||
* - 2026-01-09: 代码质量优化 - 提取魔法数字为常量定义 (修改者: moyin)
|
* - 2026-01-09: 代码质量优化 - 提取魔法数字为常量定义 (修改者: moyin)
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 1.0.1
|
||||||
* @since 2026-01-09
|
* @since 2026-01-09
|
||||||
* @lastModified 2026-01-09
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ValidationError } from 'class-validator';
|
import { ValidationError } from 'class-validator';
|
||||||
|
|||||||
@@ -17,22 +17,21 @@
|
|||||||
* - 并发控制:使用悲观锁防止竞态条件
|
* - 并发控制:使用悲观锁防止竞态条件
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 清理未使用的导入FindOptionsWhere (修改者: moyin)
|
||||||
* - 2026-01-12: 性能优化 - 集成AppLoggerService,优化查询和批量操作
|
* - 2026-01-12: 性能优化 - 集成AppLoggerService,优化查询和批量操作
|
||||||
* - 2026-01-07: 代码规范优化 - 使用统一的常量文件,提高代码质量
|
* - 2026-01-07: 代码规范优化 - 使用统一的常量文件,提高代码质量
|
||||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和方法三级注释
|
* - 2026-01-07: 代码规范优化 - 完善文件头注释和方法三级注释
|
||||||
* - 2026-01-07: 功能新增 - 添加事务支持防止并发竞态条件
|
* - 2026-01-07: 功能新增 - 添加事务支持防止并发竞态条件
|
||||||
* - 2026-01-07: 性能优化 - 优化查询语句添加LIMIT限制
|
|
||||||
* - 2026-01-07: 功能新增 - 新增existsByGameUserId方法
|
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.2.0
|
* @version 1.2.1
|
||||||
* @since 2025-01-05
|
* @since 2025-01-05
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, FindOptionsWhere, DataSource, SelectQueryBuilder } from 'typeorm';
|
import { Repository, DataSource, SelectQueryBuilder } from 'typeorm';
|
||||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||||
import { AppLoggerService } from '../../utils/logger/logger.service';
|
import { AppLoggerService } from '../../utils/logger/logger.service';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -16,28 +16,19 @@
|
|||||||
* 注意:业务逻辑已转移到 src/core/zulip_core/services/zulip_accounts_business.service.ts
|
* 注意:业务逻辑已转移到 src/core/zulip_core/services/zulip_accounts_business.service.ts
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 清理未使用的导入NotFoundException (修改者: moyin)
|
||||||
* - 2026-01-12: 代码规范优化 - 修复依赖注入配置,添加@Inject装饰器确保正确的参数注入 (修改者: moyin)
|
* - 2026-01-12: 代码规范优化 - 修复依赖注入配置,添加@Inject装饰器确保正确的参数注入 (修改者: moyin)
|
||||||
* - 2026-01-12: 功能修改 - 优化create方法错误处理,正确转换重复创建错误为ConflictException (修改者: moyin)
|
* - 2026-01-12: 功能修改 - 优化create方法错误处理,正确转换重复创建错误为ConflictException (修改者: moyin)
|
||||||
* - 2026-01-12: 架构优化 - 移除业务逻辑,转移到zulip_core业务服务 (修改者: moyin)
|
* - 2026-01-12: 架构优化 - 移除业务逻辑,转移到zulip_core业务服务 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码质量优化 - 清理重复导入,统一使用@Inject装饰器 (修改者: moyin)
|
* - 2026-01-12: 代码质量优化 - 清理重复导入,统一使用@Inject装饰器 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码质量优化 - 完成所有性能监控代码优化,统一使用createPerformanceMonitor方法 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码质量优化 - 修复所有遗漏的BigInt转换,使用列表响应构建工具方法 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码质量优化 - 完善所有BigInt转换和数组映射的优化,彻底消除重复代码 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码质量优化 - 使用基类工具方法,优化性能监控和BigInt转换,减少重复代码 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 性能优化 - 集成AppLoggerService和缓存机制,添加性能监控
|
|
||||||
* - 2026-01-07: 代码规范优化 - 使用统一的常量文件,提高代码质量
|
|
||||||
* - 2026-01-07: 代码规范优化 - 修复导入路径,完善方法三级注释
|
|
||||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和方法三级注释
|
|
||||||
* - 2026-01-07: 功能修改 - 优化异常处理逻辑,规范Repository和Service职责边界
|
|
||||||
* - 2026-01-07: 性能优化 - 移除Service层的重复唯一性检查,依赖Repository事务
|
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 2.1.0
|
* @version 2.1.1
|
||||||
* @since 2025-01-07
|
* @since 2025-01-07
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject, ConflictException, NotFoundException } from '@nestjs/common';
|
import { Injectable, Inject, ConflictException } from '@nestjs/common';
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
import { BaseZulipAccountsService } from './base_zulip_accounts.service';
|
import { BaseZulipAccountsService } from './base_zulip_accounts.service';
|
||||||
import { ZulipAccountsRepository } from './zulip_accounts.repository';
|
import { ZulipAccountsRepository } from './zulip_accounts.repository';
|
||||||
@@ -126,8 +117,10 @@ export class ZulipAccountsService extends BaseZulipAccountsService {
|
|||||||
errorMessage.includes('unique constraint')) {
|
errorMessage.includes('unique constraint')) {
|
||||||
const conflictError = new ConflictException(`游戏用户 ${createDto.gameUserId} 已存在Zulip账号关联`);
|
const conflictError = new ConflictException(`游戏用户 ${createDto.gameUserId} 已存在Zulip账号关联`);
|
||||||
monitor.error(conflictError);
|
monitor.error(conflictError);
|
||||||
|
throw conflictError;
|
||||||
} else {
|
} else {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,6 +315,7 @@ export class ZulipAccountsService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +357,7 @@ export class ZulipAccountsService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +399,7 @@ export class ZulipAccountsService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +420,7 @@ export class ZulipAccountsService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +441,7 @@ export class ZulipAccountsService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,26 +15,19 @@
|
|||||||
* 注意:业务逻辑已转移到 src/core/zulip_core/services/zulip_accounts_business.service.ts
|
* 注意:业务逻辑已转移到 src/core/zulip_core/services/zulip_accounts_business.service.ts
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 清理未使用的导入ConflictException和NotFoundException (修改者: moyin)
|
||||||
* - 2026-01-12: 架构优化 - 移除业务逻辑,转移到zulip_core业务服务 (修改者: moyin)
|
* - 2026-01-12: 架构优化 - 移除业务逻辑,转移到zulip_core业务服务 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码质量优化 - 修复导入语句,添加缺失的AppLoggerService导入 (修改者: moyin)
|
* - 2026-01-12: 代码质量优化 - 修复导入语句,添加缺失的AppLoggerService导入 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码质量优化 - 修复logger初始化问题,统一使用AppLoggerService (修改者: moyin)
|
* - 2026-01-12: 代码质量优化 - 修复logger初始化问题,统一使用AppLoggerService (修改者: moyin)
|
||||||
* - 2026-01-12: 代码质量优化 - 完成所有性能监控代码优化,统一使用createPerformanceMonitor方法 (修改者: moyin)
|
* - 2026-01-12: 代码质量优化 - 完成所有性能监控代码优化,统一使用createPerformanceMonitor方法 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码质量优化 - 修复所有遗漏的BigInt转换,使用列表响应构建工具方法 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码质量优化 - 完善所有BigInt转换和数组映射的优化,彻底消除重复代码 (修改者: moyin)
|
|
||||||
* - 2026-01-12: 代码质量优化 - 使用基类工具方法,优化性能监控和BigInt转换,减少重复代码 (修改者: moyin)
|
|
||||||
* - 2026-01-07: 代码规范优化 - 使用统一的常量文件,提高代码质量
|
|
||||||
* - 2026-01-07: 代码规范优化 - 修复导入路径,完善方法三级注释
|
|
||||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和方法三级注释
|
|
||||||
* - 2026-01-07: 功能完善 - 优化异常处理逻辑和日志记录
|
|
||||||
* - 2025-01-07: 架构优化 - 统一Service层的职责边界和接口设计
|
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 2.0.0
|
* @version 2.0.1
|
||||||
* @since 2025-01-07
|
* @since 2025-01-07
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject, ConflictException, NotFoundException } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { BaseZulipAccountsService } from './base_zulip_accounts.service';
|
import { BaseZulipAccountsService } from './base_zulip_accounts.service';
|
||||||
import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository';
|
import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository';
|
||||||
import { ZulipAccounts } from './zulip_accounts.entity';
|
import { ZulipAccounts } from './zulip_accounts.entity';
|
||||||
@@ -101,6 +94,7 @@ export class ZulipAccountsMemoryService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +140,7 @@ export class ZulipAccountsMemoryService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +254,7 @@ export class ZulipAccountsMemoryService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +277,7 @@ export class ZulipAccountsMemoryService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +298,7 @@ export class ZulipAccountsMemoryService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +319,7 @@ export class ZulipAccountsMemoryService extends BaseZulipAccountsService {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
monitor.error(error);
|
monitor.error(error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 登录核心模块测试套件
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 测试LoginCoreModule的模块配置和依赖注入
|
||||||
|
* - 验证服务提供者的正确注册和实例化
|
||||||
|
* - 确保JWT配置的正确加载和访问
|
||||||
|
* - 测试模块导出和依赖关系
|
||||||
|
*
|
||||||
|
* 测试覆盖范围:
|
||||||
|
* - 模块定义:模块实例化和配置验证
|
||||||
|
* - 服务提供者:LoginCoreService和ConfigService的注入
|
||||||
|
* - JWT配置:JWT密钥和过期时间的配置访问
|
||||||
|
* - 模块依赖:依赖模块的正确导入
|
||||||
|
* - 模块导出:服务的正确导出和可用性
|
||||||
|
*
|
||||||
|
* 测试策略:
|
||||||
|
* - 单元测试:独立测试模块配置
|
||||||
|
* - Mock测试:模拟所有外部依赖服务
|
||||||
|
* - 配置测试:验证配置项的正确读取
|
||||||
|
*
|
||||||
|
* 依赖模块:
|
||||||
|
* - Jest: 测试框架和Mock功能
|
||||||
|
* - NestJS Testing: 提供测试模块和依赖注入
|
||||||
|
*
|
||||||
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 清理未使用的导入(UsersService) (修改者: moyin)
|
||||||
|
* - 2026-01-15: 代码规范优化 - 添加文件头注释 (修改者: moyin)
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.2
|
||||||
|
* @since 2025-12-17
|
||||||
|
* @lastModified 2026-01-15
|
||||||
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { LoginCoreService } from './login_core.service';
|
import { LoginCoreService } from './login_core.service';
|
||||||
import { UsersService } from '../db/users/users.service';
|
|
||||||
import { EmailService } from '../utils/email/email.service';
|
import { EmailService } from '../utils/email/email.service';
|
||||||
import { VerificationService } from '../utils/verification/verification.service';
|
import { VerificationService } from '../utils/verification/verification.service';
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,16 @@
|
|||||||
* - 为business层提供可复用的服务
|
* - 为business层提供可复用的服务
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-15: 代码规范优化 - 提取手机号查找为私有方法消除重复代码 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码规范优化 - 提取魔法数字为常量,拆分过长方法,消除代码重复 (修改者: moyin)
|
* - 2026-01-12: 代码规范优化 - 提取魔法数字为常量,拆分过长方法,消除代码重复 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码规范优化 - 添加LoginCoreService类注释,完善类职责和方法说明 (修改者: moyin)
|
* - 2026-01-12: 代码规范优化 - 添加LoginCoreService类注释,完善类职责和方法说明 (修改者: moyin)
|
||||||
* - 2026-01-12: 代码规范优化 - 处理TODO项,移除短信发送相关的TODO注释 (修改者: moyin)
|
* - 2026-01-12: 代码规范优化 - 处理TODO项,移除短信发送相关的TODO注释 (修改者: moyin)
|
||||||
* - 2025-01-07: 代码规范优化 - 清理未使用的导入(EmailSendResult, crypto)
|
* - 2025-01-07: 代码规范优化 - 清理未使用的导入(EmailSendResult, crypto)
|
||||||
* - 2025-01-07: 代码规范优化 - 修复常量命名(saltRounds -> SALT_ROUNDS)
|
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.1.0
|
* @version 1.1.1
|
||||||
* @since 2025-12-17
|
* @since 2025-12-17
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-15
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, ForbiddenException, Inject } from '@nestjs/common';
|
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, ForbiddenException, Inject } from '@nestjs/common';
|
||||||
@@ -243,8 +243,7 @@ export class LoginCoreService {
|
|||||||
|
|
||||||
// 如果邮箱未找到,尝试手机号查找(简单验证)
|
// 如果邮箱未找到,尝试手机号查找(简单验证)
|
||||||
if (!user && this.isPhoneNumber(identifier)) {
|
if (!user && this.isPhoneNumber(identifier)) {
|
||||||
const users = await this.usersService.findAll();
|
user = await this.findUserByPhone(identifier);
|
||||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户不存在
|
// 用户不存在
|
||||||
@@ -340,9 +339,8 @@ export class LoginCoreService {
|
|||||||
|
|
||||||
// 检查手机号是否已存在
|
// 检查手机号是否已存在
|
||||||
if (phone) {
|
if (phone) {
|
||||||
const users = await this.usersService.findAll();
|
const phoneExists = await this.isPhoneExists(phone);
|
||||||
const existingPhone = users.find((u: Users) => u.phone === phone);
|
if (phoneExists) {
|
||||||
if (existingPhone) {
|
|
||||||
throw new ConflictException('手机号已存在');
|
throw new ConflictException('手机号已存在');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,8 +553,7 @@ export class LoginCoreService {
|
|||||||
throw new BadRequestException('邮箱未验证,无法重置密码');
|
throw new BadRequestException('邮箱未验证,无法重置密码');
|
||||||
}
|
}
|
||||||
} else if (this.isPhoneNumber(identifier)) {
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
const users = await this.usersService.findAll();
|
user = await this.findUserByPhone(identifier);
|
||||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -616,8 +613,7 @@ export class LoginCoreService {
|
|||||||
if (this.isEmail(identifier)) {
|
if (this.isEmail(identifier)) {
|
||||||
user = await this.usersService.findByEmail(identifier);
|
user = await this.usersService.findByEmail(identifier);
|
||||||
} else if (this.isPhoneNumber(identifier)) {
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
const users = await this.usersService.findAll();
|
user = await this.findUserByPhone(identifier);
|
||||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -847,6 +843,30 @@ export class LoginCoreService {
|
|||||||
return phoneRegex.test(str.replace(/\s/g, ''));
|
return phoneRegex.test(str.replace(/\s/g, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过手机号查找用户
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @returns 用户信息或null
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async findUserByPhone(phone: string): Promise<Users | null> {
|
||||||
|
const users = await this.usersService.findAll();
|
||||||
|
return users.find((u: Users) => u.phone === phone) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查手机号是否已存在
|
||||||
|
*
|
||||||
|
* @param phone 手机号
|
||||||
|
* @returns 是否存在
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async isPhoneExists(phone: string): Promise<boolean> {
|
||||||
|
const user = await this.findUserByPhone(phone);
|
||||||
|
return user !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证码登录
|
* 验证码登录
|
||||||
*
|
*
|
||||||
@@ -888,8 +908,7 @@ export class LoginCoreService {
|
|||||||
}
|
}
|
||||||
} else if (this.isPhoneNumber(identifier)) {
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
// 手机号登录
|
// 手机号登录
|
||||||
const users = await this.usersService.findAll();
|
user = await this.findUserByPhone(identifier);
|
||||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
|
||||||
verificationType = VerificationCodeType.SMS_VERIFICATION;
|
verificationType = VerificationCodeType.SMS_VERIFICATION;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException('请提供有效的邮箱或手机号');
|
throw new BadRequestException('请提供有效的邮箱或手机号');
|
||||||
@@ -964,8 +983,7 @@ export class LoginCoreService {
|
|||||||
throw new BadRequestException('邮箱未验证,无法使用验证码登录');
|
throw new BadRequestException('邮箱未验证,无法使用验证码登录');
|
||||||
}
|
}
|
||||||
} else if (this.isPhoneNumber(identifier)) {
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
const users = await this.usersService.findAll();
|
user = await this.findUserByPhone(identifier);
|
||||||
user = users.find((u: Users) => u.phone === identifier) || null;
|
|
||||||
verificationType = VerificationCodeType.SMS_VERIFICATION;
|
verificationType = VerificationCodeType.SMS_VERIFICATION;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException('请提供有效的邮箱或手机号');
|
throw new BadRequestException('请提供有效的邮箱或手机号');
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ Zulip Core 是应用的核心聊天集成模块,提供完整的Zulip聊天服
|
|||||||
### getAllAccountLinks()
|
### getAllAccountLinks()
|
||||||
获取所有活跃的账号关联信息,用于系统管理和监控。
|
获取所有活跃的账号关联信息,用于系统管理和监控。
|
||||||
|
|
||||||
|
## 用户注册功能
|
||||||
|
|
||||||
|
### registerUser()
|
||||||
|
在Zulip服务器上注册新用户账号,包含邮箱验证、密码生成和API Key获取。
|
||||||
|
|
||||||
|
## 用户管理功能
|
||||||
|
|
||||||
|
### checkUserExists()
|
||||||
|
检查指定邮箱的用户是否存在于Zulip服务器。
|
||||||
|
|
||||||
|
### getUserInfo()
|
||||||
|
根据邮箱获取用户的详细信息,包含用户ID、状态和权限信息。
|
||||||
|
|
||||||
|
### validateUserCredentials()
|
||||||
|
验证用户的API Key是否有效,用于登录认证。
|
||||||
|
|
||||||
|
### getAllUsers()
|
||||||
|
从Zulip服务器获取所有用户列表,用于管理和统计。
|
||||||
|
|
||||||
## 配置管理功能
|
## 配置管理功能
|
||||||
|
|
||||||
### getAllMapConfigs()
|
### getAllMapConfigs()
|
||||||
@@ -101,6 +120,67 @@ Zulip Core 是应用的核心聊天集成模块,提供完整的Zulip聊天服
|
|||||||
### validateConfig()
|
### validateConfig()
|
||||||
验证配置文件的完整性和正确性,确保系统正常运行。
|
验证配置文件的完整性和正确性,确保系统正常运行。
|
||||||
|
|
||||||
|
## 动态配置管理功能
|
||||||
|
|
||||||
|
### testZulipConnection()
|
||||||
|
测试与Zulip服务器的连接状态,用于健康检查和故障诊断。
|
||||||
|
|
||||||
|
### getZulipStreams()
|
||||||
|
从Zulip服务器获取所有Stream列表,用于配置同步。
|
||||||
|
|
||||||
|
### getZulipTopics()
|
||||||
|
获取指定Stream的所有Topic列表,用于交互对象配置。
|
||||||
|
|
||||||
|
### getConfig()
|
||||||
|
获取当前缓存的配置信息,优先使用内存缓存。
|
||||||
|
|
||||||
|
### syncConfig()
|
||||||
|
手动触发配置同步,从Zulip服务器获取最新配置并更新本地文件。
|
||||||
|
|
||||||
|
### getConfigStatus()
|
||||||
|
获取配置管理器的状态信息,包含同步时间、配置来源等。
|
||||||
|
|
||||||
|
### getBackupFiles()
|
||||||
|
获取配置备份文件列表,用于配置恢复和版本管理。
|
||||||
|
|
||||||
|
### restoreFromBackup()
|
||||||
|
从指定的备份文件恢复配置,支持配置回滚。
|
||||||
|
|
||||||
|
## 错误处理功能
|
||||||
|
|
||||||
|
### handleZulipError()
|
||||||
|
处理Zulip API错误,分析错误类型并决定处理策略。
|
||||||
|
|
||||||
|
### enableDegradedMode()
|
||||||
|
启用服务降级模式,在Zulip服务不可用时提供基础功能。
|
||||||
|
|
||||||
|
### enableNormalMode()
|
||||||
|
恢复正常服务模式,从降级状态切换回正常状态。
|
||||||
|
|
||||||
|
### retryWithBackoff()
|
||||||
|
使用指数退避算法进行重试,避免对服务造成过大压力。
|
||||||
|
|
||||||
|
### handleConnectionError()
|
||||||
|
处理网络连接错误,决定是否重试和启用降级模式。
|
||||||
|
|
||||||
|
### executeWithTimeout()
|
||||||
|
带超时控制的操作执行,超时时自动取消并返回错误。
|
||||||
|
|
||||||
|
### scheduleReconnect()
|
||||||
|
调度自动重连,在连接断开时使用指数退避策略重连。
|
||||||
|
|
||||||
|
### cancelReconnect()
|
||||||
|
取消正在进行的重连尝试,清理重连状态。
|
||||||
|
|
||||||
|
### checkServiceHealth()
|
||||||
|
检查服务健康状态,返回综合健康报告。
|
||||||
|
|
||||||
|
### getServiceStatus()
|
||||||
|
获取当前服务状态(正常/降级/不可用)。
|
||||||
|
|
||||||
|
### getLoadStatus()
|
||||||
|
获取系统负载状态,用于连接限流决策。
|
||||||
|
|
||||||
## 安全管理功能
|
## 安全管理功能
|
||||||
|
|
||||||
### encryptApiKey()
|
### encryptApiKey()
|
||||||
@@ -129,6 +209,32 @@ Zulip Core 是应用的核心聊天集成模块,提供完整的Zulip聊天服
|
|||||||
### getPerformanceMetrics()
|
### getPerformanceMetrics()
|
||||||
获取系统性能指标,包含响应时间和吞吐量统计。
|
获取系统性能指标,包含响应时间和吞吐量统计。
|
||||||
|
|
||||||
|
## 监控日志功能
|
||||||
|
|
||||||
|
### logConnection()
|
||||||
|
记录WebSocket连接事件日志,包含连接、断开和错误事件。
|
||||||
|
|
||||||
|
### logApiCall()
|
||||||
|
记录Zulip API调用日志,包含响应时间和结果状态。
|
||||||
|
|
||||||
|
### logMessageForward()
|
||||||
|
记录消息转发日志,包含成功率和延迟统计。
|
||||||
|
|
||||||
|
### confirmOperation()
|
||||||
|
记录操作确认信息,用于审计和追踪。
|
||||||
|
|
||||||
|
### sendAlert()
|
||||||
|
发送系统告警通知,支持不同严重级别。
|
||||||
|
|
||||||
|
### getStats()
|
||||||
|
获取监控统计信息,包含连接、API调用和消息统计。
|
||||||
|
|
||||||
|
### getRecentAlerts()
|
||||||
|
获取最近的告警列表,用于问题排查。
|
||||||
|
|
||||||
|
### resetStats()
|
||||||
|
重置监控统计数据,用于周期性统计。
|
||||||
|
|
||||||
## 使用的项目内部依赖
|
## 使用的项目内部依赖
|
||||||
|
|
||||||
### RedisModule (来自 ../redis/redis.module)
|
### RedisModule (来自 ../redis/redis.module)
|
||||||
@@ -354,7 +460,7 @@ const newKey = await apiKeySecurityService.rotateApiKey('user123');
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 版本信息
|
## 版本信息
|
||||||
- **版本**: 1.1.1
|
- **版本**: 1.2.0
|
||||||
- **作者**: moyin
|
- **作者**: moyin
|
||||||
- **创建时间**: 2025-12-25
|
- **创建时间**: 2025-12-25
|
||||||
- **最后修改**: 2026-01-07
|
- **最后修改**: 2026-01-15
|
||||||
@@ -69,6 +69,7 @@ export interface CreateZulipAccountResult {
|
|||||||
export interface GenerateApiKeyResult {
|
export interface GenerateApiKeyResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
userId?: number; // 添加 userId,从 profile 中获取
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,40 +302,44 @@ export class ZulipAccountService {
|
|||||||
|
|
||||||
// 尝试获取已有用户的信息
|
// 尝试获取已有用户的信息
|
||||||
const userInfo = await this.getExistingUserInfo(request.email);
|
const userInfo = await this.getExistingUserInfo(request.email);
|
||||||
if (userInfo.success) {
|
|
||||||
// 尝试为已有用户生成API Key
|
// 尝试为已有用户生成API Key(同时可以获取 userId)
|
||||||
const apiKeyResult = await this.generateApiKeyForUser(request.email, request.password || '');
|
const apiKeyResult = await this.generateApiKeyForUser(request.email, request.password || '');
|
||||||
|
|
||||||
this.logger.log('Zulip账号绑定成功(已存在)', {
|
// 优先使用 userInfo 中的 userId,其次使用 apiKeyResult 中的 userId
|
||||||
operation: 'handleExistingUser',
|
const finalUserId = userInfo.userId ?? apiKeyResult.userId;
|
||||||
email: request.email,
|
|
||||||
userId: userInfo.userId,
|
if (finalUserId === undefined) {
|
||||||
hasApiKey: apiKeyResult.success,
|
this.logger.error('用户已存在但无法获取用户ID,绑定失败', {
|
||||||
apiKeyError: apiKeyResult.success ? undefined : apiKeyResult.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
userId: userInfo.userId,
|
|
||||||
email: request.email,
|
|
||||||
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
|
|
||||||
isExistingUser: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.logger.warn('用户已存在但无法获取详细信息,仍返回绑定成功', {
|
|
||||||
operation: 'handleExistingUser',
|
operation: 'handleExistingUser',
|
||||||
email: request.email,
|
email: request.email,
|
||||||
getUserInfoError: userInfo.error,
|
getUserInfoError: userInfo.error,
|
||||||
|
apiKeyError: apiKeyResult.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
userId: undefined,
|
error: `用户已存在但无法获取用户ID`,
|
||||||
email: request.email,
|
errorCode: 'USER_ID_NOT_FOUND',
|
||||||
apiKey: undefined,
|
|
||||||
isExistingUser: true,
|
isExistingUser: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log('Zulip账号绑定成功(已存在)', {
|
||||||
|
operation: 'handleExistingUser',
|
||||||
|
email: request.email,
|
||||||
|
userId: finalUserId,
|
||||||
|
hasApiKey: apiKeyResult.success,
|
||||||
|
apiKeyError: apiKeyResult.success ? undefined : apiKeyResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: finalUserId,
|
||||||
|
email: request.email,
|
||||||
|
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
|
||||||
|
isExistingUser: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -421,39 +426,43 @@ export class ZulipAccountService {
|
|||||||
|
|
||||||
// 尝试获取已有用户信息
|
// 尝试获取已有用户信息
|
||||||
const userInfo = await this.getExistingUserInfo(request.email);
|
const userInfo = await this.getExistingUserInfo(request.email);
|
||||||
if (userInfo.success) {
|
|
||||||
// 尝试为已有用户生成API Key
|
// 尝试为已有用户生成API Key(同时可以获取 userId)
|
||||||
const apiKeyResult = await this.generateApiKeyForUser(request.email, password);
|
const apiKeyResult = await this.generateApiKeyForUser(request.email, password);
|
||||||
|
|
||||||
this.logger.log('Zulip账号绑定成功(API创建时发现已存在)', {
|
// 优先使用 userInfo 中的 userId,其次使用 apiKeyResult 中的 userId
|
||||||
operation: 'handleCreateUserError',
|
const finalUserId = userInfo.userId ?? apiKeyResult.userId;
|
||||||
email: request.email,
|
|
||||||
userId: userInfo.userId,
|
if (finalUserId === undefined) {
|
||||||
hasApiKey: apiKeyResult.success,
|
this.logger.error('用户已存在但无法获取用户ID,绑定失败', {
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
userId: userInfo.userId,
|
|
||||||
email: request.email,
|
|
||||||
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
|
|
||||||
isExistingUser: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.logger.warn('用户已存在但无法获取详细信息', {
|
|
||||||
operation: 'handleCreateUserError',
|
operation: 'handleCreateUserError',
|
||||||
email: request.email,
|
email: request.email,
|
||||||
getUserInfoError: userInfo.error,
|
getUserInfoError: userInfo.error,
|
||||||
|
apiKeyError: apiKeyResult.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
userId: undefined,
|
error: `用户已存在但无法获取用户ID`,
|
||||||
email: request.email,
|
errorCode: 'USER_ID_NOT_FOUND',
|
||||||
apiKey: undefined,
|
|
||||||
isExistingUser: true,
|
isExistingUser: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log('Zulip账号绑定成功(API创建时发现已存在)', {
|
||||||
|
operation: 'handleCreateUserError',
|
||||||
|
email: request.email,
|
||||||
|
userId: finalUserId,
|
||||||
|
hasApiKey: apiKeyResult.success,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userId: finalUserId,
|
||||||
|
email: request.email,
|
||||||
|
apiKey: apiKeyResult.success ? apiKeyResult.apiKey : undefined,
|
||||||
|
isExistingUser: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他类型的错误
|
// 其他类型的错误
|
||||||
@@ -515,12 +524,14 @@ export class ZulipAccountService {
|
|||||||
this.logger.log('API Key生成成功', {
|
this.logger.log('API Key生成成功', {
|
||||||
operation: 'generateApiKeyForUser',
|
operation: 'generateApiKeyForUser',
|
||||||
email,
|
email,
|
||||||
|
userId: profile.user_id,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
|
userId: profile.user_id, // 返回从 profile 获取的 user_id
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
case 'position':
|
case 'position':
|
||||||
await this.handlePosition(ws, message);
|
await this.handlePosition(ws, message);
|
||||||
break;
|
break;
|
||||||
|
case 'change_map':
|
||||||
|
await this.handleChangeMap(ws, message);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`未知消息类型: ${messageType}`);
|
this.logger.warn(`未知消息类型: ${messageType}`);
|
||||||
this.sendError(ws, `未知消息类型: ${messageType}`);
|
this.sendError(ws, `未知消息类型: ${messageType}`);
|
||||||
@@ -254,7 +257,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
* 处理聊天消息
|
* 处理聊天消息
|
||||||
*
|
*
|
||||||
* @param ws WebSocket 连接实例
|
* @param ws WebSocket 连接实例
|
||||||
* @param message 聊天消息(包含 content, scope)
|
* @param message 聊天消息(包含 content, scope, mapId)
|
||||||
*/
|
*/
|
||||||
private async handleChat(ws: ExtendedWebSocket, message: any) {
|
private async handleChat(ws: ExtendedWebSocket, message: any) {
|
||||||
if (!ws.authenticated) {
|
if (!ws.authenticated) {
|
||||||
@@ -271,7 +274,8 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
const result = await this.chatService.sendChatMessage({
|
const result = await this.chatService.sendChatMessage({
|
||||||
socketId: ws.id,
|
socketId: ws.id,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
scope: message.scope || 'local'
|
scope: message.scope || 'local',
|
||||||
|
mapId: message.mapId || ws.currentMap, // 支持指定目标地图
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -335,6 +339,82 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理切换地图
|
||||||
|
*
|
||||||
|
* @param ws WebSocket 连接实例
|
||||||
|
* @param message 切换地图消息(包含 mapId)
|
||||||
|
*/
|
||||||
|
private async handleChangeMap(ws: ExtendedWebSocket, message: any) {
|
||||||
|
if (!ws.authenticated) {
|
||||||
|
this.sendError(ws, '请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.mapId) {
|
||||||
|
this.sendError(ws, '地图ID不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oldMapId = ws.currentMap;
|
||||||
|
const newMapId = message.mapId;
|
||||||
|
|
||||||
|
// 如果地图相同,直接返回成功
|
||||||
|
if (oldMapId === newMapId) {
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
t: 'map_changed',
|
||||||
|
mapId: newMapId,
|
||||||
|
message: '已在当前地图'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新房间
|
||||||
|
this.leaveMapRoom(ws.id, oldMapId);
|
||||||
|
this.joinMapRoom(ws.id, newMapId);
|
||||||
|
ws.currentMap = newMapId;
|
||||||
|
|
||||||
|
// 更新会话中的地图信息(使用默认位置)
|
||||||
|
await this.chatService.updatePlayerPosition({
|
||||||
|
socketId: ws.id,
|
||||||
|
x: message.x || 400,
|
||||||
|
y: message.y || 300,
|
||||||
|
mapId: newMapId
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知客户端切换成功
|
||||||
|
this.sendMessage(ws, {
|
||||||
|
t: 'map_changed',
|
||||||
|
mapId: newMapId,
|
||||||
|
oldMapId: oldMapId,
|
||||||
|
message: '地图切换成功'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 向旧地图广播玩家离开
|
||||||
|
this.broadcastToMap(oldMapId, {
|
||||||
|
t: 'player_left',
|
||||||
|
userId: ws.userId,
|
||||||
|
username: ws.username,
|
||||||
|
mapId: oldMapId
|
||||||
|
});
|
||||||
|
|
||||||
|
// 向新地图广播玩家加入
|
||||||
|
this.broadcastToMap(newMapId, {
|
||||||
|
t: 'player_joined',
|
||||||
|
userId: ws.userId,
|
||||||
|
username: ws.username,
|
||||||
|
mapId: newMapId
|
||||||
|
}, ws.id);
|
||||||
|
|
||||||
|
this.logger.log(`用户切换地图: ${ws.username} (${oldMapId} -> ${newMapId})`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('切换地图处理失败', error);
|
||||||
|
this.sendError(ws, '切换地图处理失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理连接关闭
|
* 处理连接关闭
|
||||||
*
|
*
|
||||||
|
|||||||
106
src/gateway/zulip/README.md
Normal file
106
src/gateway/zulip/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Zulip Gateway Module
|
||||||
|
|
||||||
|
## 📋 模块概述
|
||||||
|
|
||||||
|
Zulip网关模块,负责提供Zulip相关功能的HTTP API接口。
|
||||||
|
|
||||||
|
## 🏗️ 架构定位
|
||||||
|
|
||||||
|
- **层级**: Gateway层(网关层)
|
||||||
|
- **职责**: HTTP协议处理、API接口暴露、请求验证
|
||||||
|
- **依赖**: Business层的ZulipModule
|
||||||
|
|
||||||
|
## 📁 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/gateway/zulip/
|
||||||
|
├── dynamic_config.controller.ts # 动态配置管理API
|
||||||
|
├── websocket_docs.controller.ts # WebSocket文档API
|
||||||
|
├── websocket_openapi.controller.ts # WebSocket OpenAPI规范
|
||||||
|
├── websocket_test.controller.ts # WebSocket测试工具
|
||||||
|
├── zulip_accounts.controller.ts # Zulip账号管理API
|
||||||
|
├── zulip.gateway.module.ts # 网关模块定义
|
||||||
|
└── README.md # 本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 主要功能
|
||||||
|
|
||||||
|
### 1. 动态配置管理 (DynamicConfigController)
|
||||||
|
- 获取当前配置
|
||||||
|
- 同步远程配置
|
||||||
|
- 配置状态查询
|
||||||
|
- 备份管理
|
||||||
|
|
||||||
|
### 2. WebSocket文档 (WebSocketDocsController)
|
||||||
|
- 提供WebSocket API使用文档
|
||||||
|
- 消息格式示例
|
||||||
|
- 连接示例代码
|
||||||
|
|
||||||
|
### 3. WebSocket OpenAPI (WebSocketOpenApiController)
|
||||||
|
- 在Swagger中展示WebSocket接口
|
||||||
|
- 提供测试工具推荐
|
||||||
|
- 架构信息展示
|
||||||
|
|
||||||
|
### 4. WebSocket测试工具 (WebSocketTestController)
|
||||||
|
- 交互式WebSocket测试页面
|
||||||
|
- 支持连接、认证、消息发送测试
|
||||||
|
- API调用监控功能
|
||||||
|
|
||||||
|
### 5. Zulip账号管理 (ZulipAccountsController)
|
||||||
|
- Zulip账号关联CRUD操作
|
||||||
|
- 账号验证和统计
|
||||||
|
- 批量管理功能
|
||||||
|
|
||||||
|
## 🔗 依赖关系
|
||||||
|
|
||||||
|
```
|
||||||
|
ZulipGatewayModule
|
||||||
|
├─ imports: ZulipModule (Business层)
|
||||||
|
├─ imports: AuthModule (Business层)
|
||||||
|
└─ controllers: [所有Controller]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 使用示例
|
||||||
|
|
||||||
|
### 在AppModule中导入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ZulipGatewayModule } from './gateway/zulip/zulip.gateway.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// ... 其他模块
|
||||||
|
ZulipGatewayModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 架构规范
|
||||||
|
|
||||||
|
### Gateway层职责
|
||||||
|
- ✅ HTTP协议处理
|
||||||
|
- ✅ 请求参数验证(DTO)
|
||||||
|
- ✅ 调用Business层服务
|
||||||
|
- ✅ 响应格式转换
|
||||||
|
- ✅ 错误处理和转换
|
||||||
|
|
||||||
|
### Gateway层禁止
|
||||||
|
- ❌ 包含业务逻辑
|
||||||
|
- ❌ 直接访问数据库
|
||||||
|
- ❌ 直接调用Core层(应通过Business层)
|
||||||
|
- ❌ 包含复杂的业务规则
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [架构文档](../../docs/ARCHITECTURE.md)
|
||||||
|
- [Zulip Business模块](../../business/zulip/README.md)
|
||||||
|
- [开发指南](../../docs/development/backend_development_guide.md)
|
||||||
|
|
||||||
|
## 🔄 最近更新
|
||||||
|
|
||||||
|
- 2026-01-14: 架构优化 - 从Business层分离Controller到Gateway层 (moyin)
|
||||||
|
|
||||||
|
## 👥 维护者
|
||||||
|
|
||||||
|
- moyin
|
||||||
@@ -6,9 +6,26 @@
|
|||||||
* - 支持配置查询、同步、状态检查
|
* - 支持配置查询、同步、状态检查
|
||||||
* - 提供备份管理功能
|
* - 提供备份管理功能
|
||||||
*
|
*
|
||||||
* @author assistant
|
* 架构定位:
|
||||||
* @version 2.0.0
|
* - 层级:Gateway层(网关层)
|
||||||
|
* - 职责:HTTP协议处理、API接口暴露
|
||||||
|
* - 依赖:调用Business层的ZulipModule服务
|
||||||
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - API接口:提供RESTful风格的配置管理接口
|
||||||
|
* - 协议处理:处理HTTP请求和响应
|
||||||
|
* - 参数验证:验证请求参数格式
|
||||||
|
* - 错误转换:将业务异常转换为HTTP响应
|
||||||
|
*
|
||||||
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin)
|
||||||
|
* - 2026-01-12: 功能新增 - 初始创建统一配置管理控制器 (修改者: moyin)
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 3.0.0
|
||||||
* @since 2026-01-12
|
* @since 2026-01-12
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
* - 展示消息格式和事件类型
|
* - 展示消息格式和事件类型
|
||||||
* - 提供连接示例和测试工具
|
* - 提供连接示例和测试工具
|
||||||
*
|
*
|
||||||
|
* 架构定位:
|
||||||
|
* - 层级:Gateway层(网关层)
|
||||||
|
* - 职责:HTTP协议处理、文档接口暴露
|
||||||
|
* - 依赖:无业务逻辑依赖,纯文档展示
|
||||||
|
*
|
||||||
* 职责分离:
|
* 职责分离:
|
||||||
* - API文档:提供完整的WebSocket API使用说明
|
* - API文档:提供完整的WebSocket API使用说明
|
||||||
* - 示例代码:提供各种编程语言的连接示例
|
* - 示例代码:提供各种编程语言的连接示例
|
||||||
@@ -13,12 +18,13 @@
|
|||||||
* - 开发指导:提供最佳实践和故障排除指南
|
* - 开发指导:提供最佳实践和故障排除指南
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
|
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.0.1
|
* @version 2.0.0
|
||||||
* @since 2025-01-07
|
* @since 2025-01-07
|
||||||
* @lastModified 2026-01-07
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket OpenAPI 文档控制器
|
* WebSocket OpenAPI 文档控制器
|
||||||
*
|
*
|
||||||
* 专门用于在OpenAPI/Swagger中展示WebSocket接口
|
* 功能描述:
|
||||||
* 通过REST API的方式描述WebSocket的消息格式和交互流程
|
* - 专门用于在OpenAPI/Swagger中展示WebSocket接口
|
||||||
|
* - 通过REST API的方式描述WebSocket的消息格式和交互流程
|
||||||
|
* - 提供WebSocket连接信息和测试工具推荐
|
||||||
|
*
|
||||||
|
* 架构定位:
|
||||||
|
* - 层级:Gateway层(网关层)
|
||||||
|
* - 职责:HTTP协议处理、OpenAPI文档暴露
|
||||||
|
* - 依赖:无业务逻辑依赖,纯文档展示
|
||||||
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - 文档展示:在Swagger中展示WebSocket消息格式
|
||||||
|
* - 连接信息:提供WebSocket连接配置和认证信息
|
||||||
|
* - 消息流程:展示WebSocket消息交互流程
|
||||||
|
* - 测试工具:提供测试工具推荐和示例代码
|
||||||
|
*
|
||||||
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin)
|
||||||
|
* - 2026-01-09: 功能新增 - 初始创建WebSocket OpenAPI文档控制器 (修改者: moyin)
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 2.0.0
|
||||||
* @since 2026-01-09
|
* @since 2026-01-09
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Controller, Get, Post, Body } from '@nestjs/common';
|
import { Controller, Get, Post, Body } from '@nestjs/common';
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket 测试页面控制器
|
* WebSocket 测试页面控制器
|
||||||
*
|
*
|
||||||
* 提供一个简单的WebSocket测试界面,可以直接在浏览器中测试WebSocket连接
|
* 功能描述:
|
||||||
* 包含API调用监控功能,帮助前端开发者了解接口调用情况
|
* - 提供一个简单的WebSocket测试界面,可以直接在浏览器中测试WebSocket连接
|
||||||
|
* - 包含API调用监控功能,帮助前端开发者了解接口调用情况
|
||||||
|
* - 支持聊天测试和通知系统测试两种模式
|
||||||
|
*
|
||||||
|
* 架构定位:
|
||||||
|
* - 层级:Gateway层(网关层)
|
||||||
|
* - 职责:HTTP协议处理、测试页面暴露
|
||||||
|
* - 依赖:无业务逻辑依赖,纯测试工具
|
||||||
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - 测试界面:提供交互式WebSocket测试页面
|
||||||
|
* - 连接测试:支持WebSocket连接、认证、消息发送测试
|
||||||
|
* - API监控:实时显示HTTP请求和响应信息
|
||||||
|
* - 通知测试:提供通知系统功能测试
|
||||||
|
*
|
||||||
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin)
|
||||||
|
* - 2026-01-09: 功能新增 - 初始创建WebSocket测试页面控制器 (修改者: moyin)
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.1.0
|
* @version 2.0.0
|
||||||
* @since 2026-01-09
|
* @since 2026-01-09
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Controller, Get, Res } from '@nestjs/common';
|
import { Controller, Get, Res } from '@nestjs/common';
|
||||||
58
src/gateway/zulip/zulip.gateway.module.ts
Normal file
58
src/gateway/zulip/zulip.gateway.module.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Zulip网关模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供Zulip相关的HTTP API接口
|
||||||
|
* - 提供WebSocket测试和文档功能
|
||||||
|
* - 提供动态配置管理接口
|
||||||
|
* - 提供Zulip账号管理接口
|
||||||
|
*
|
||||||
|
* 架构说明:
|
||||||
|
* - Gateway层:负责HTTP协议处理和API接口暴露
|
||||||
|
* - 依赖Business层:调用ZulipModule提供的业务服务
|
||||||
|
* - 职责分离:只做协议转换,不包含业务逻辑
|
||||||
|
*
|
||||||
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 架构优化 - 从Business层分离Controller到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2026-01-14
|
||||||
|
* @lastModified 2026-01-14
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
// Gateway层控制器
|
||||||
|
import { DynamicConfigController } from './dynamic_config.controller';
|
||||||
|
import { WebSocketDocsController } from './websocket_docs.controller';
|
||||||
|
import { WebSocketOpenApiController } from './websocket_openapi.controller';
|
||||||
|
import { WebSocketTestController } from './websocket_test.controller';
|
||||||
|
import { ZulipAccountsController } from './zulip_accounts.controller';
|
||||||
|
// 依赖Business层模块
|
||||||
|
import { ZulipModule } from '../../business/zulip/zulip.module';
|
||||||
|
import { AuthModule } from '../../business/auth/auth.module';
|
||||||
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// 导入Business层的Zulip模块
|
||||||
|
ZulipModule,
|
||||||
|
// 导入认证模块(用于JwtAuthGuard)
|
||||||
|
AuthModule,
|
||||||
|
// 导入登录核心模块(JwtAuthGuard依赖)
|
||||||
|
LoginCoreModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
// 动态配置管理控制器
|
||||||
|
DynamicConfigController,
|
||||||
|
// WebSocket API文档控制器
|
||||||
|
WebSocketDocsController,
|
||||||
|
// WebSocket OpenAPI规范控制器
|
||||||
|
WebSocketOpenApiController,
|
||||||
|
// WebSocket测试工具控制器
|
||||||
|
WebSocketTestController,
|
||||||
|
// Zulip账号关联管理控制器
|
||||||
|
ZulipAccountsController,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ZulipGatewayModule {}
|
||||||
@@ -26,9 +26,9 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { ZulipAccountsController } from './zulip_accounts.controller';
|
import { ZulipAccountsController } from './zulip_accounts.controller';
|
||||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt_auth.guard';
|
||||||
import { AppLoggerService } from '../../core/utils/logger/logger.service';
|
import { AppLoggerService } from '../../core/utils/logger/logger.service';
|
||||||
import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service';
|
import { ZulipAccountsBusinessService } from '../../business/zulip/services/zulip_accounts_business.service';
|
||||||
|
|
||||||
describe('ZulipAccountsController', () => {
|
describe('ZulipAccountsController', () => {
|
||||||
let controller: ZulipAccountsController;
|
let controller: ZulipAccountsController;
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
* - 集成性能监控和结构化日志记录
|
* - 集成性能监控和结构化日志记录
|
||||||
* - 实现统一的错误处理和响应格式
|
* - 实现统一的错误处理和响应格式
|
||||||
*
|
*
|
||||||
|
* 架构定位:
|
||||||
|
* - 层级:Gateway层(网关层)
|
||||||
|
* - 职责:HTTP协议处理、API接口暴露
|
||||||
|
* - 依赖:调用Business层的ZulipAccountsBusinessService
|
||||||
|
*
|
||||||
* 职责分离:
|
* 职责分离:
|
||||||
* - API接口:提供RESTful风格的HTTP接口
|
* - API接口:提供RESTful风格的HTTP接口
|
||||||
* - 参数验证:使用DTO进行请求参数验证
|
* - 参数验证:使用DTO进行请求参数验证
|
||||||
@@ -17,13 +22,16 @@
|
|||||||
* - 日志记录:使用AppLoggerService记录结构化日志
|
* - 日志记录:使用AppLoggerService记录结构化日志
|
||||||
*
|
*
|
||||||
* 最近修改:
|
* 最近修改:
|
||||||
|
* - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 代码质量优化 - 移除未使用的requestLogger属性 (修改者: moyin)
|
||||||
|
* - 2026-01-14: 代码质量优化 - 移除未使用的导入 (修改者: moyin)
|
||||||
* - 2026-01-12: 性能优化 - 集成AppLoggerService和性能监控,优化错误处理
|
* - 2026-01-12: 性能优化 - 集成AppLoggerService和性能监控,优化错误处理
|
||||||
* - 2025-01-07: 初始创建 - 实现基础的CRUD和管理接口
|
* - 2025-01-07: 初始创建 - 实现基础的CRUD和管理接口
|
||||||
*
|
*
|
||||||
* @author angjustinl
|
* @author angjustinl
|
||||||
* @version 1.1.0
|
* @version 2.0.0
|
||||||
* @since 2025-01-07
|
* @since 2025-01-07
|
||||||
* @lastModified 2026-01-12
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -50,9 +58,7 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt_auth.guard';
|
||||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
|
||||||
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
|
|
||||||
import { AppLoggerService } from '../../core/utils/logger/logger.service';
|
import { AppLoggerService } from '../../core/utils/logger/logger.service';
|
||||||
import {
|
import {
|
||||||
CreateZulipAccountDto,
|
CreateZulipAccountDto,
|
||||||
@@ -72,8 +78,6 @@ import {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
export class ZulipAccountsController {
|
export class ZulipAccountsController {
|
||||||
private readonly requestLogger: any;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: any,
|
@Inject('ZulipAccountsService') private readonly zulipAccountsService: any,
|
||||||
@Inject(AppLoggerService) private readonly logger: AppLoggerService,
|
@Inject(AppLoggerService) private readonly logger: AppLoggerService,
|
||||||
@@ -7,27 +7,33 @@
|
|||||||
* - 测试真实的网络请求和响应处理
|
* - 测试真实的网络请求和响应处理
|
||||||
*
|
*
|
||||||
* 测试范围:
|
* 测试范围:
|
||||||
* - WebSocket → ZulipService → ZulipClientPool → ZulipClient → Zulip API
|
* - WebSocket → ChatService → ZulipClientPool → ZulipClient → Zulip API
|
||||||
|
*
|
||||||
|
* 更新记录:
|
||||||
|
* - 2026-01-14: 重构后更新 - 使用新的四层架构模块
|
||||||
|
* - ChatService 替代 ZulipService
|
||||||
|
* - ChatSessionService 替代 SessionManagerService
|
||||||
*
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 2.0.0
|
||||||
* @since 2026-01-10
|
* @since 2026-01-10
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { ZulipService } from '../../src/business/zulip/zulip.service';
|
import { ChatService } from '../../src/business/chat/chat.service';
|
||||||
|
import { ChatSessionService } from '../../src/business/chat/services/chat_session.service';
|
||||||
import { ZulipClientPoolService } from '../../src/core/zulip_core/services/zulip_client_pool.service';
|
import { ZulipClientPoolService } from '../../src/core/zulip_core/services/zulip_client_pool.service';
|
||||||
import { ZulipClientService, ZulipClientInstance } from '../../src/core/zulip_core/services/zulip_client.service';
|
import { ZulipClientService, ZulipClientInstance } from '../../src/core/zulip_core/services/zulip_client.service';
|
||||||
import { SessionManagerService } from '../../src/business/zulip/services/session_manager.service';
|
|
||||||
import { AppModule } from '../../src/app.module';
|
import { AppModule } from '../../src/app.module';
|
||||||
|
|
||||||
describe('ChatMessage E2E Integration', () => {
|
describe('ChatMessage E2E Integration', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let zulipService: ZulipService;
|
let chatService: ChatService;
|
||||||
let zulipClientPool: ZulipClientPoolService;
|
let zulipClientPool: ZulipClientPoolService;
|
||||||
let zulipClient: ZulipClientService;
|
let zulipClient: ZulipClientService;
|
||||||
let sessionManager: SessionManagerService;
|
let sessionManager: ChatSessionService;
|
||||||
|
|
||||||
// 模拟的Zulip客户端
|
// 模拟的Zulip客户端
|
||||||
let mockZulipSdkClient: any;
|
let mockZulipSdkClient: any;
|
||||||
@@ -48,11 +54,11 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
|
||||||
// 获取服务实例
|
// 获取服务实例(使用新的四层架构模块)
|
||||||
zulipService = moduleFixture.get<ZulipService>(ZulipService);
|
chatService = moduleFixture.get<ChatService>(ChatService);
|
||||||
zulipClientPool = moduleFixture.get<ZulipClientPoolService>(ZulipClientPoolService);
|
zulipClientPool = moduleFixture.get<ZulipClientPoolService>(ZulipClientPoolService);
|
||||||
zulipClient = moduleFixture.get<ZulipClientService>(ZulipClientService);
|
zulipClient = moduleFixture.get<ZulipClientService>(ZulipClientService);
|
||||||
sessionManager = moduleFixture.get<SessionManagerService>(SessionManagerService);
|
sessionManager = moduleFixture.get<ChatSessionService>(ChatSessionService);
|
||||||
|
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
@@ -110,7 +116,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
describe('完整的聊天消息流程', () => {
|
describe('完整的聊天消息流程', () => {
|
||||||
it('应该成功处理从登录到消息发送的完整流程', async () => {
|
it('应该成功处理从登录到消息发送的完整流程', async () => {
|
||||||
// 1. 模拟用户登录
|
// 1. 模拟用户登录
|
||||||
const loginResult = await zulipService.handlePlayerLogin({
|
const loginResult = await chatService.handlePlayerLogin({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
token: 'valid-jwt-token', // 这里需要有效的JWT token
|
token: 'valid-jwt-token', // 这里需要有效的JWT token
|
||||||
});
|
});
|
||||||
@@ -121,7 +127,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
expect(loginResult.sessionId).toBeDefined();
|
expect(loginResult.sessionId).toBeDefined();
|
||||||
|
|
||||||
// 2. 发送聊天消息
|
// 2. 发送聊天消息
|
||||||
const chatResult = await zulipService.sendChatMessage({
|
const chatResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'Hello from E2E test!',
|
content: 'Hello from E2E test!',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -153,7 +159,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
const chatResult = await zulipService.sendChatMessage({
|
const chatResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'Hello from E2E test with mock session!',
|
content: 'Hello from E2E test with mock session!',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -175,7 +181,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 测试本地消息
|
// 测试本地消息
|
||||||
const localResult = await zulipService.sendChatMessage({
|
const localResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'Local message test',
|
content: 'Local message test',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -191,7 +197,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
expect(localCall[0].to).toBe('Whale Port'); // 应该路由到地图对应的Stream
|
expect(localCall[0].to).toBe('Whale Port'); // 应该路由到地图对应的Stream
|
||||||
|
|
||||||
// 测试全局消息
|
// 测试全局消息
|
||||||
const globalResult = await zulipService.sendChatMessage({
|
const globalResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'Global message test',
|
content: 'Global message test',
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
@@ -218,7 +224,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 测试正常消息
|
// 测试正常消息
|
||||||
const normalResult = await zulipService.sendChatMessage({
|
const normalResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'This is a normal message',
|
content: 'This is a normal message',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -226,7 +232,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
expect(normalResult.success).toBe(true);
|
expect(normalResult.success).toBe(true);
|
||||||
|
|
||||||
// 测试空消息
|
// 测试空消息
|
||||||
const emptyResult = await zulipService.sendChatMessage({
|
const emptyResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: '',
|
content: '',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -235,7 +241,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
|
|
||||||
// 测试过长消息
|
// 测试过长消息
|
||||||
const longMessage = 'A'.repeat(2000); // 假设限制是1000字符
|
const longMessage = 'A'.repeat(2000); // 假设限制是1000字符
|
||||||
const longResult = await zulipService.sendChatMessage({
|
const longResult = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: longMessage,
|
content: longMessage,
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -262,7 +268,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
code: 'STREAM_NOT_FOUND',
|
code: 'STREAM_NOT_FOUND',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await zulipService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'This message will fail',
|
content: 'This message will fail',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -286,7 +292,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
// 模拟网络异常
|
// 模拟网络异常
|
||||||
mockZulipSdkClient.messages.send.mockRejectedValueOnce(new Error('Network timeout'));
|
mockZulipSdkClient.messages.send.mockRejectedValueOnce(new Error('Network timeout'));
|
||||||
|
|
||||||
const result = await zulipService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: 'This will timeout',
|
content: 'This will timeout',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -423,7 +429,7 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
|
|
||||||
// 发送大量消息
|
// 发送大量消息
|
||||||
const promises = Array.from({ length: messageCount }, (_, i) =>
|
const promises = Array.from({ length: messageCount }, (_, i) =>
|
||||||
zulipService.sendChatMessage({
|
chatService.sendChatMessage({
|
||||||
socketId: testSocketId,
|
socketId: testSocketId,
|
||||||
content: `Performance test message ${i}`,
|
content: `Performance test message ${i}`,
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -445,4 +451,4 @@ describe('ChatMessage E2E Integration', () => {
|
|||||||
expect(avgTimePerMessage).toBeLessThan(100);
|
expect(avgTimePerMessage).toBeLessThan(100);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,16 +12,23 @@
|
|||||||
* - 大量消息批量处理性能
|
* - 大量消息批量处理性能
|
||||||
* - 内存使用和资源清理
|
* - 内存使用和资源清理
|
||||||
*
|
*
|
||||||
|
* 更新记录:
|
||||||
|
* - 2026-01-14: 重构后更新 - 使用新的四层架构模块
|
||||||
|
* - ChatService 替代 ZulipService
|
||||||
|
* - ChatSessionService 替代 SessionManagerService
|
||||||
|
* - ChatFilterService 替代 MessageFilterService
|
||||||
|
*
|
||||||
* @author moyin
|
* @author moyin
|
||||||
* @version 1.0.0
|
* @version 2.0.0
|
||||||
* @since 2026-01-10
|
* @since 2026-01-10
|
||||||
|
* @lastModified 2026-01-14
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ZulipService } from '../../../src/business/zulip/zulip.service';
|
import { ChatService } from '../../../src/business/chat/chat.service';
|
||||||
|
import { ChatSessionService } from '../../../src/business/chat/services/chat_session.service';
|
||||||
|
import { ChatFilterService } from '../../../src/business/chat/services/chat_filter.service';
|
||||||
import { ZulipClientPoolService } from '../../../src/core/zulip_core/services/zulip_client_pool.service';
|
import { ZulipClientPoolService } from '../../../src/core/zulip_core/services/zulip_client_pool.service';
|
||||||
import { SessionManagerService } from '../../../src/business/zulip/services/session_manager.service';
|
|
||||||
import { MessageFilterService } from '../../../src/business/zulip/services/message_filter.service';
|
|
||||||
|
|
||||||
// 模拟WebSocket网关
|
// 模拟WebSocket网关
|
||||||
class MockWebSocketGateway {
|
class MockWebSocketGateway {
|
||||||
@@ -45,8 +52,8 @@ class MockWebSocketGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Zulip聊天性能测试', () => {
|
describe('Zulip聊天性能测试', () => {
|
||||||
let zulipService: ZulipService;
|
let chatService: ChatService;
|
||||||
let sessionManager: SessionManagerService;
|
let sessionManager: ChatSessionService;
|
||||||
let mockWebSocketGateway: MockWebSocketGateway;
|
let mockWebSocketGateway: MockWebSocketGateway;
|
||||||
let mockZulipClientPool: any;
|
let mockZulipClientPool: any;
|
||||||
|
|
||||||
@@ -88,17 +95,17 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ZulipService,
|
ChatService,
|
||||||
{
|
{
|
||||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||||||
useValue: mockZulipClientPool,
|
useValue: mockZulipClientPool,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: SessionManagerService,
|
provide: ChatSessionService,
|
||||||
useValue: mockSessionManager,
|
useValue: mockSessionManager,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MessageFilterService,
|
provide: ChatFilterService,
|
||||||
useValue: mockMessageFilter,
|
useValue: mockMessageFilter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,12 +130,12 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
zulipService = module.get<ZulipService>(ZulipService);
|
chatService = module.get<ChatService>(ChatService);
|
||||||
sessionManager = module.get<SessionManagerService>(SessionManagerService);
|
sessionManager = module.get<ChatSessionService>(ChatSessionService);
|
||||||
|
|
||||||
// 设置WebSocket网关
|
// 设置WebSocket网关
|
||||||
mockWebSocketGateway = new MockWebSocketGateway();
|
mockWebSocketGateway = new MockWebSocketGateway();
|
||||||
zulipService.setWebSocketGateway(mockWebSocketGateway as any);
|
chatService.setWebSocketGateway(mockWebSocketGateway as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -140,7 +147,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
it('应该在50ms内完成游戏内广播', async () => {
|
it('应该在50ms内完成游戏内广播', async () => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const result = await zulipService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
socketId: 'test-socket',
|
socketId: 'test-socket',
|
||||||
content: 'Performance test message',
|
content: 'Performance test message',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -165,7 +172,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const result = await zulipService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
socketId: 'test-socket',
|
socketId: 'test-socket',
|
||||||
content: 'Async test message',
|
content: 'Async test message',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -186,7 +193,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const promises = Array.from({ length: messageCount }, (_, i) =>
|
const promises = Array.from({ length: messageCount }, (_, i) =>
|
||||||
zulipService.sendChatMessage({
|
chatService.sendChatMessage({
|
||||||
socketId: `socket-${i}`,
|
socketId: `socket-${i}`,
|
||||||
content: `Concurrent message ${i}`,
|
content: `Concurrent message ${i}`,
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -210,7 +217,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
it('应该正确广播给地图内的所有玩家', async () => {
|
it('应该正确广播给地图内的所有玩家', async () => {
|
||||||
await zulipService.sendChatMessage({
|
await chatService.sendChatMessage({
|
||||||
socketId: 'sender-socket',
|
socketId: 'sender-socket',
|
||||||
content: 'Broadcast test message',
|
content: 'Broadcast test message',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -234,7 +241,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
|
|
||||||
// 创建批量消息
|
// 创建批量消息
|
||||||
const batchPromises = Array.from({ length: batchSize }, (_, i) =>
|
const batchPromises = Array.from({ length: batchSize }, (_, i) =>
|
||||||
zulipService.sendChatMessage({
|
chatService.sendChatMessage({
|
||||||
socketId: 'batch-socket',
|
socketId: 'batch-socket',
|
||||||
content: `Batch message ${i}`,
|
content: `Batch message ${i}`,
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -267,7 +274,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
|
|
||||||
// 模拟会话创建
|
// 模拟会话创建
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
await zulipService.handlePlayerLogin({
|
await chatService.handlePlayerLogin({
|
||||||
socketId: sessionId,
|
socketId: sessionId,
|
||||||
token: 'valid-jwt-token',
|
token: 'valid-jwt-token',
|
||||||
});
|
});
|
||||||
@@ -275,7 +282,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
|
|
||||||
// 清理所有会话
|
// 清理所有会话
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
await zulipService.handlePlayerLogout(sessionId);
|
await chatService.handlePlayerLogout(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证资源清理
|
// 验证资源清理
|
||||||
@@ -294,7 +301,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
|
|
||||||
// 处理大量消息
|
// 处理大量消息
|
||||||
const promises = largeDataSet.map((item, i) =>
|
const promises = largeDataSet.map((item, i) =>
|
||||||
zulipService.sendChatMessage({
|
chatService.sendChatMessage({
|
||||||
socketId: `memory-test-${i}`,
|
socketId: `memory-test-${i}`,
|
||||||
content: `Memory test ${item.id}: ${item.data.substring(0, 50)}...`,
|
content: `Memory test ${item.id}: ${item.data.substring(0, 50)}...`,
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -322,7 +329,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
it('应该快速处理无效会话', async () => {
|
it('应该快速处理无效会话', async () => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const result = await zulipService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
socketId: 'invalid-socket',
|
socketId: 'invalid-socket',
|
||||||
content: 'This should fail quickly',
|
content: 'This should fail quickly',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -341,7 +348,7 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
// 模拟Zulip服务异常
|
// 模拟Zulip服务异常
|
||||||
mockZulipClientPool.sendMessage.mockRejectedValue(new Error('Zulip service unavailable'));
|
mockZulipClientPool.sendMessage.mockRejectedValue(new Error('Zulip service unavailable'));
|
||||||
|
|
||||||
const result = await zulipService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
socketId: 'test-socket',
|
socketId: 'test-socket',
|
||||||
content: 'Message during Zulip outage',
|
content: 'Message during Zulip outage',
|
||||||
scope: 'local',
|
scope: 'local',
|
||||||
@@ -355,4 +362,4 @@ describe('Zulip聊天性能测试', () => {
|
|||||||
expect(broadcastMessages).toHaveLength(1);
|
expect(broadcastMessages).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user