forked from datawhale/whale-town-end
Merge pull request 'release/v2.0.0' (#35) from release/v2.0.0 into main
Reviewed-on: datawhale/whale-town-end#35
This commit is contained in:
@@ -1,856 +0,0 @@
|
||||
# 后端开发规范指南
|
||||
|
||||
## 一、文档概述
|
||||
|
||||
### 1.1 文档目的
|
||||
|
||||
本文档定义了 Datawhale Town 后端开发的编码规范、注释标准、业务逻辑设计原则和日志记录要求,确保代码质量、可维护性和系统稳定性。
|
||||
|
||||
### 1.2 适用范围
|
||||
|
||||
- 所有后端开发人员
|
||||
- 代码审查人员
|
||||
- 系统维护人员
|
||||
|
||||
---
|
||||
|
||||
## 二、注释规范
|
||||
|
||||
### 2.1 模块注释
|
||||
|
||||
每个功能模块文件必须包含模块级注释,说明模块用途、主要功能和依赖关系。
|
||||
|
||||
**格式要求:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 玩家管理模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理玩家注册、登录、信息更新等核心功能
|
||||
* - 管理玩家角色皮肤和个人资料
|
||||
* - 提供玩家数据的 CRUD 操作
|
||||
*
|
||||
* 依赖模块:
|
||||
* - AuthService: 身份验证服务
|
||||
* - DatabaseService: 数据库操作服务
|
||||
* - LoggerService: 日志记录服务
|
||||
*
|
||||
* @author 开发者姓名
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-13
|
||||
*/
|
||||
```
|
||||
|
||||
### 2.2 类注释
|
||||
|
||||
每个类必须包含类级注释,说明类的职责、主要方法和使用场景。
|
||||
|
||||
**格式要求:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 玩家服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 处理玩家相关的业务逻辑
|
||||
* - 管理玩家状态和数据
|
||||
* - 提供玩家操作的统一接口
|
||||
*
|
||||
* 主要方法:
|
||||
* - createPlayer(): 创建新玩家
|
||||
* - updatePlayerInfo(): 更新玩家信息
|
||||
* - getPlayerById(): 根据ID获取玩家信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - 玩家注册登录流程
|
||||
* - 个人陈列室数据管理
|
||||
* - 广场玩家状态同步
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlayerService {
|
||||
// 类实现
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 方法注释
|
||||
|
||||
每个方法必须包含详细的方法注释,说明功能、参数、返回值、异常和业务逻辑。
|
||||
|
||||
**格式要求:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 创建新玩家
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据邮箱创建新的玩家账户,包含基础信息初始化和默认配置设置
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证邮箱格式和白名单
|
||||
* 2. 检查邮箱是否已存在
|
||||
* 3. 生成唯一玩家ID
|
||||
* 4. 初始化默认角色皮肤和个人信息
|
||||
* 5. 创建对应的个人陈列室
|
||||
* 6. 记录创建日志
|
||||
*
|
||||
* @param email 玩家邮箱地址,必须符合邮箱格式且在白名单中
|
||||
* @param nickname 玩家昵称,长度3-20字符,不能包含特殊字符
|
||||
* @param avatarSkin 角色皮肤ID,必须是1-8之间的有效值
|
||||
* @returns Promise<Player> 创建成功的玩家对象
|
||||
*
|
||||
* @throws BadRequestException 当邮箱格式错误或不在白名单中
|
||||
* @throws ConflictException 当邮箱已存在时
|
||||
* @throws InternalServerErrorException 当数据库操作失败时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const player = await playerService.createPlayer(
|
||||
* 'user@datawhale.club',
|
||||
* '数据鲸鱼',
|
||||
* '1'
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
async createPlayer(
|
||||
email: string,
|
||||
nickname: string,
|
||||
avatarSkin: string
|
||||
): Promise<Player> {
|
||||
// 方法实现
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 复杂业务逻辑注释
|
||||
|
||||
对于复杂的业务逻辑,必须添加行内注释说明每个步骤的目的和处理逻辑。
|
||||
|
||||
**示例:**
|
||||
|
||||
```typescript
|
||||
async joinRoom(roomId: string, playerId: string): Promise<Room> {
|
||||
// 1. 参数验证 - 确保房间ID和玩家ID格式正确
|
||||
if (!roomId || !playerId) {
|
||||
this.logger.warn(`房间加入失败:参数无效`, { roomId, playerId });
|
||||
throw new BadRequestException('房间ID和玩家ID不能为空');
|
||||
}
|
||||
|
||||
// 2. 获取房间信息 - 检查房间是否存在
|
||||
const room = await this.roomRepository.findById(roomId);
|
||||
if (!room) {
|
||||
this.logger.warn(`房间加入失败:房间不存在`, { roomId, playerId });
|
||||
throw new NotFoundException('房间不存在');
|
||||
}
|
||||
|
||||
// 3. 检查房间状态 - 只有等待中的房间才能加入
|
||||
if (room.status !== RoomStatus.WAITING) {
|
||||
this.logger.warn(`房间加入失败:房间状态不允许加入`, {
|
||||
roomId,
|
||||
playerId,
|
||||
currentStatus: room.status
|
||||
});
|
||||
throw new BadRequestException('游戏已开始,无法加入房间');
|
||||
}
|
||||
|
||||
// 4. 检查房间容量 - 防止超过最大人数限制
|
||||
if (room.players.length >= room.maxPlayers) {
|
||||
this.logger.warn(`房间加入失败:房间已满`, {
|
||||
roomId,
|
||||
playerId,
|
||||
currentPlayers: room.players.length,
|
||||
maxPlayers: room.maxPlayers
|
||||
});
|
||||
throw new BadRequestException('房间已满');
|
||||
}
|
||||
|
||||
// 5. 检查玩家是否已在房间中 - 防止重复加入
|
||||
if (room.players.includes(playerId)) {
|
||||
this.logger.info(`玩家已在房间中,跳过加入操作`, { roomId, playerId });
|
||||
return room;
|
||||
}
|
||||
|
||||
// 6. 执行加入操作 - 更新房间玩家列表
|
||||
try {
|
||||
room.players.push(playerId);
|
||||
const updatedRoom = await this.roomRepository.save(room);
|
||||
|
||||
// 7. 记录成功日志
|
||||
this.logger.info(`玩家成功加入房间`, {
|
||||
roomId,
|
||||
playerId,
|
||||
currentPlayers: updatedRoom.players.length,
|
||||
maxPlayers: updatedRoom.maxPlayers
|
||||
});
|
||||
|
||||
return updatedRoom;
|
||||
} catch (error) {
|
||||
// 8. 异常处理 - 记录错误并抛出
|
||||
this.logger.error(`房间加入操作数据库错误`, {
|
||||
roomId,
|
||||
playerId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new InternalServerErrorException('房间加入失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、业务逻辑设计原则
|
||||
|
||||
### 3.1 全面性原则
|
||||
|
||||
每个业务方法必须考虑所有可能的情况,包括正常流程、异常情况和边界条件。
|
||||
|
||||
**必须考虑的情况:**
|
||||
|
||||
| 类别 | 具体情况 | 处理方式 |
|
||||
|------|---------|---------|
|
||||
| **输入验证** | 参数为空、格式错误、超出范围 | 参数校验 + 异常抛出 |
|
||||
| **权限检查** | 未登录、权限不足、令牌过期 | 身份验证 + 权限验证 |
|
||||
| **资源状态** | 资源不存在、状态不正确、已被占用 | 状态检查 + 业务规则验证 |
|
||||
| **并发控制** | 同时操作、数据竞争、锁冲突 | 事务处理 + 乐观锁/悲观锁 |
|
||||
| **系统异常** | 数据库连接失败、网络超时、内存不足 | 异常捕获 + 降级处理 |
|
||||
| **业务规则** | 违反业务约束、超出限制、状态冲突 | 业务规则验证 + 友好提示 |
|
||||
|
||||
### 3.2 防御性编程
|
||||
|
||||
采用防御性编程思想,对所有外部输入和依赖进行验证和保护。
|
||||
|
||||
**实现要求:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 更新玩家信息 - 防御性编程示例
|
||||
*/
|
||||
async updatePlayerInfo(
|
||||
playerId: string,
|
||||
updateData: UpdatePlayerDto
|
||||
): Promise<Player> {
|
||||
// 1. 输入参数防御性检查
|
||||
if (!playerId) {
|
||||
this.logger.warn('更新玩家信息失败:玩家ID为空');
|
||||
throw new BadRequestException('玩家ID不能为空');
|
||||
}
|
||||
|
||||
if (!updateData || Object.keys(updateData).length === 0) {
|
||||
this.logger.warn('更新玩家信息失败:更新数据为空', { playerId });
|
||||
throw new BadRequestException('更新数据不能为空');
|
||||
}
|
||||
|
||||
// 2. 数据格式验证
|
||||
if (updateData.nickname) {
|
||||
if (updateData.nickname.length < 3 || updateData.nickname.length > 20) {
|
||||
this.logger.warn('更新玩家信息失败:昵称长度不符合要求', {
|
||||
playerId,
|
||||
nicknameLength: updateData.nickname.length
|
||||
});
|
||||
throw new BadRequestException('昵称长度必须在3-20字符之间');
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.avatarSkin) {
|
||||
const validSkins = ['1', '2', '3', '4', '5', '6', '7', '8'];
|
||||
if (!validSkins.includes(updateData.avatarSkin)) {
|
||||
this.logger.warn('更新玩家信息失败:角色皮肤ID无效', {
|
||||
playerId,
|
||||
avatarSkin: updateData.avatarSkin
|
||||
});
|
||||
throw new BadRequestException('角色皮肤ID必须在1-8之间');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 玩家存在性检查
|
||||
const existingPlayer = await this.playerRepository.findById(playerId);
|
||||
if (!existingPlayer) {
|
||||
this.logger.warn('更新玩家信息失败:玩家不存在', { playerId });
|
||||
throw new NotFoundException('玩家不存在');
|
||||
}
|
||||
|
||||
// 4. 昵称唯一性检查(如果更新昵称)
|
||||
if (updateData.nickname && updateData.nickname !== existingPlayer.nickname) {
|
||||
const nicknameExists = await this.playerRepository.findByNickname(updateData.nickname);
|
||||
if (nicknameExists) {
|
||||
this.logger.warn('更新玩家信息失败:昵称已存在', {
|
||||
playerId,
|
||||
nickname: updateData.nickname
|
||||
});
|
||||
throw new ConflictException('昵称已被使用');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行更新操作(使用事务保证数据一致性)
|
||||
try {
|
||||
const updatedPlayer = await this.playerRepository.update(playerId, updateData);
|
||||
|
||||
this.logger.info('玩家信息更新成功', {
|
||||
playerId,
|
||||
updatedFields: Object.keys(updateData),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return updatedPlayer;
|
||||
} catch (error) {
|
||||
this.logger.error('更新玩家信息数据库操作失败', {
|
||||
playerId,
|
||||
updateData,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new InternalServerErrorException('更新失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 异常处理策略
|
||||
|
||||
建立统一的异常处理策略,确保所有异常都能被正确捕获和处理。
|
||||
|
||||
**异常分类和处理:**
|
||||
|
||||
| 异常类型 | HTTP状态码 | 处理策略 | 日志级别 |
|
||||
|---------|-----------|---------|---------|
|
||||
| **BadRequestException** | 400 | 参数验证失败,返回具体错误信息 | WARN |
|
||||
| **UnauthorizedException** | 401 | 身份验证失败,要求重新登录 | WARN |
|
||||
| **ForbiddenException** | 403 | 权限不足,拒绝访问 | WARN |
|
||||
| **NotFoundException** | 404 | 资源不存在,返回友好提示 | WARN |
|
||||
| **ConflictException** | 409 | 资源冲突,如重复创建 | WARN |
|
||||
| **InternalServerErrorException** | 500 | 系统内部错误,记录详细日志 | ERROR |
|
||||
|
||||
---
|
||||
|
||||
## 四、日志系统使用指南
|
||||
|
||||
### 4.1 日志服务简介
|
||||
|
||||
项目使用统一的 `AppLoggerService` 提供日志记录功能,集成了 Pino 高性能日志库,支持自动敏感信息过滤和请求上下文绑定。
|
||||
|
||||
### 4.2 在服务中使用日志
|
||||
|
||||
**依赖注入:**
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppLoggerService } from '../core/utils/logger/logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly logger: AppLoggerService
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 日志级别和使用场景
|
||||
|
||||
| 级别 | 使用场景 | 示例 |
|
||||
|------|---------|------|
|
||||
| **ERROR** | 系统错误、异常情况、数据库连接失败 | 数据库连接超时、第三方服务调用失败 |
|
||||
| **WARN** | 业务警告、参数错误、权限不足 | 用户输入无效参数、尝试访问不存在的资源 |
|
||||
| **INFO** | 重要业务操作、状态变更、关键流程 | 用户登录成功、房间创建、玩家加入广场 |
|
||||
| **DEBUG** | 调试信息、详细执行流程 | 方法调用参数、中间计算结果 |
|
||||
| **FATAL** | 致命错误、系统不可用 | 数据库完全不可用、关键服务宕机 |
|
||||
| **TRACE** | 极细粒度调试信息 | 循环内的变量状态、算法执行步骤 |
|
||||
|
||||
### 4.4 标准日志格式
|
||||
|
||||
**推荐的日志上下文格式:**
|
||||
|
||||
```typescript
|
||||
// 成功操作日志
|
||||
this.logger.info('操作描述', {
|
||||
operation: '操作类型',
|
||||
userId: '用户ID',
|
||||
resourceId: '资源ID',
|
||||
params: '关键参数',
|
||||
result: '操作结果',
|
||||
duration: '执行时间(ms)',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 警告日志
|
||||
this.logger.warn('警告描述', {
|
||||
operation: '操作类型',
|
||||
userId: '用户ID',
|
||||
reason: '警告原因',
|
||||
params: '相关参数',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 错误日志
|
||||
this.logger.error('错误描述', {
|
||||
operation: '操作类型',
|
||||
userId: '用户ID',
|
||||
error: error.message,
|
||||
params: '相关参数',
|
||||
timestamp: new Date().toISOString()
|
||||
}, error.stack);
|
||||
```
|
||||
|
||||
### 4.5 请求上下文绑定
|
||||
|
||||
**在 Controller 中使用:**
|
||||
|
||||
```typescript
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly logger: AppLoggerService) {}
|
||||
|
||||
@Get(':id')
|
||||
async getUser(@Param('id') id: string, @Req() req: Request) {
|
||||
// 绑定请求上下文
|
||||
const requestLogger = this.logger.bindRequest(req, 'UserController');
|
||||
|
||||
requestLogger.info('开始获取用户信息', { userId: id });
|
||||
|
||||
try {
|
||||
const user = await this.userService.findById(id);
|
||||
requestLogger.info('用户信息获取成功', { userId: id });
|
||||
return user;
|
||||
} catch (error) {
|
||||
requestLogger.error('用户信息获取失败', error.stack, {
|
||||
userId: id,
|
||||
reason: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 业务方法日志记录最佳实践
|
||||
|
||||
**完整的业务方法日志记录示例:**
|
||||
|
||||
```typescript
|
||||
async createPlayer(email: string, nickname: string): Promise<Player> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.info('开始创建玩家', {
|
||||
operation: 'createPlayer',
|
||||
email,
|
||||
nickname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 参数验证
|
||||
if (!email || !nickname) {
|
||||
this.logger.warn('创建玩家失败:参数无效', {
|
||||
operation: 'createPlayer',
|
||||
email,
|
||||
nickname,
|
||||
reason: 'invalid_parameters'
|
||||
});
|
||||
throw new BadRequestException('邮箱和昵称不能为空');
|
||||
}
|
||||
|
||||
// 2. 邮箱格式验证
|
||||
if (!this.isValidEmail(email)) {
|
||||
this.logger.warn('创建玩家失败:邮箱格式无效', {
|
||||
operation: 'createPlayer',
|
||||
email,
|
||||
nickname
|
||||
});
|
||||
throw new BadRequestException('邮箱格式不正确');
|
||||
}
|
||||
|
||||
// 3. 检查邮箱是否已存在
|
||||
const existingPlayer = await this.playerRepository.findByEmail(email);
|
||||
if (existingPlayer) {
|
||||
this.logger.warn('创建玩家失败:邮箱已存在', {
|
||||
operation: 'createPlayer',
|
||||
email,
|
||||
nickname,
|
||||
existingPlayerId: existingPlayer.id
|
||||
});
|
||||
throw new ConflictException('邮箱已被使用');
|
||||
}
|
||||
|
||||
// 4. 创建玩家
|
||||
const player = await this.playerRepository.create({
|
||||
email,
|
||||
nickname,
|
||||
avatarSkin: '1', // 默认皮肤
|
||||
createTime: new Date()
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.info('玩家创建成功', {
|
||||
operation: 'createPlayer',
|
||||
playerId: player.id,
|
||||
email,
|
||||
nickname,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return player;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof BadRequestException ||
|
||||
error instanceof ConflictException) {
|
||||
// 业务异常,重新抛出
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 系统异常,记录详细日志
|
||||
this.logger.error('创建玩家系统异常', {
|
||||
operation: 'createPlayer',
|
||||
email,
|
||||
nickname,
|
||||
error: error.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error.stack);
|
||||
|
||||
throw new InternalServerErrorException('创建玩家失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 必须记录日志的操作
|
||||
|
||||
| 操作类型 | 日志级别 | 记录内容 |
|
||||
|---------|---------|---------|
|
||||
| **用户认证** | INFO/WARN | 登录成功/失败、令牌生成/验证 |
|
||||
| **数据变更** | INFO | 创建、更新、删除操作 |
|
||||
| **权限检查** | WARN | 权限验证失败、非法访问尝试 |
|
||||
| **系统异常** | ERROR | 异常堆栈、错误上下文、影响范围 |
|
||||
| **性能监控** | INFO | 慢查询、高并发操作、资源使用 |
|
||||
| **安全事件** | WARN/ERROR | 恶意请求、频繁操作、异常行为 |
|
||||
|
||||
### 4.8 敏感信息保护
|
||||
|
||||
日志系统会自动过滤以下敏感字段:
|
||||
- `password` - 密码
|
||||
- `token` - 令牌
|
||||
- `secret` - 密钥
|
||||
- `authorization` - 授权信息
|
||||
- `cardNo` - 卡号
|
||||
|
||||
**注意:** 包含这些关键词的字段会被自动替换为 `[REDACTED]`
|
||||
|
||||
---
|
||||
|
||||
## 五、代码审查检查清单
|
||||
|
||||
### 5.1 注释检查
|
||||
|
||||
- [ ] 模块文件包含完整的模块级注释
|
||||
- [ ] 每个类都有详细的类级注释
|
||||
- [ ] 每个公共方法都有完整的方法注释
|
||||
- [ ] 复杂业务逻辑有行内注释说明
|
||||
- [ ] 注释内容准确,与代码实现一致
|
||||
|
||||
### 5.2 业务逻辑检查
|
||||
|
||||
- [ ] 考虑了所有可能的输入情况
|
||||
- [ ] 包含完整的参数验证
|
||||
- [ ] 处理了所有可能的异常情况
|
||||
- [ ] 实现了适当的权限检查
|
||||
- [ ] 考虑了并发和竞态条件
|
||||
|
||||
### 5.3 日志记录检查
|
||||
|
||||
- [ ] 关键业务操作都有日志记录
|
||||
- [ ] 日志级别使用正确
|
||||
- [ ] 日志格式符合规范
|
||||
- [ ] 包含足够的上下文信息
|
||||
- [ ] 敏感信息已脱敏处理
|
||||
|
||||
### 5.4 异常处理检查
|
||||
|
||||
- [ ] 所有异常都被正确捕获
|
||||
- [ ] 异常类型选择合适
|
||||
- [ ] 异常信息对用户友好
|
||||
- [ ] 系统异常有详细的错误日志
|
||||
- [ ] 不会泄露敏感的系统信息
|
||||
|
||||
---
|
||||
|
||||
## 六、最佳实践示例
|
||||
|
||||
### 6.1 完整的服务类示例
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 广场管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 管理中央广场的玩家状态和位置同步
|
||||
* - 处理玩家进入和离开广场的逻辑
|
||||
* - 维护广场在线玩家列表(最多50人)
|
||||
*
|
||||
* 依赖模块:
|
||||
* - PlayerService: 玩家信息服务
|
||||
* - WebSocketGateway: WebSocket通信网关
|
||||
* - RedisService: 缓存服务
|
||||
* - LoggerService: 日志记录服务
|
||||
*
|
||||
* @author 开发团队
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-13
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlazaService {
|
||||
private readonly logger = new Logger(PlazaService.name);
|
||||
private readonly MAX_PLAYERS = 50;
|
||||
|
||||
constructor(
|
||||
private readonly playerService: PlayerService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly webSocketGateway: WebSocketGateway
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 玩家进入广场
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理玩家进入中央广场的逻辑,包括人数限制检查、位置分配和状态同步
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证玩家身份和权限
|
||||
* 2. 检查广场当前人数是否超限
|
||||
* 3. 为玩家分配初始位置
|
||||
* 4. 更新Redis中的在线玩家列表
|
||||
* 5. 向其他玩家广播新玩家进入消息
|
||||
* 6. 向新玩家发送当前广场状态
|
||||
*
|
||||
* @param playerId 玩家ID,必须是有效的已注册玩家
|
||||
* @param socketId WebSocket连接ID,用于消息推送
|
||||
* @returns Promise<PlazaPlayerInfo> 玩家在广场的信息
|
||||
*
|
||||
* @throws UnauthorizedException 当玩家身份验证失败时
|
||||
* @throws BadRequestException 当广场人数已满时
|
||||
* @throws InternalServerErrorException 当系统操作失败时
|
||||
*/
|
||||
async enterPlaza(playerId: string, socketId: string): Promise<PlazaPlayerInfo> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.info('玩家尝试进入广场', {
|
||||
operation: 'enterPlaza',
|
||||
playerId,
|
||||
socketId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证玩家身份
|
||||
const player = await this.playerService.getPlayerById(playerId);
|
||||
if (!player) {
|
||||
this.logger.warn('进入广场失败:玩家不存在', {
|
||||
operation: 'enterPlaza',
|
||||
playerId,
|
||||
socketId
|
||||
});
|
||||
throw new UnauthorizedException('玩家身份验证失败');
|
||||
}
|
||||
|
||||
// 2. 检查广场人数限制
|
||||
const currentPlayers = await this.redisService.scard('plaza:online_players');
|
||||
if (currentPlayers >= this.MAX_PLAYERS) {
|
||||
this.logger.warn('进入广场失败:人数已满', {
|
||||
operation: 'enterPlaza',
|
||||
playerId,
|
||||
currentPlayers,
|
||||
maxPlayers: this.MAX_PLAYERS
|
||||
});
|
||||
throw new BadRequestException('广场人数已满,请稍后再试');
|
||||
}
|
||||
|
||||
// 3. 检查玩家是否已在广场中
|
||||
const isAlreadyInPlaza = await this.redisService.sismember('plaza:online_players', playerId);
|
||||
if (isAlreadyInPlaza) {
|
||||
this.logger.info('玩家已在广场中,更新连接信息', {
|
||||
operation: 'enterPlaza',
|
||||
playerId,
|
||||
socketId
|
||||
});
|
||||
|
||||
// 更新Socket连接映射
|
||||
await this.redisService.hset('plaza:player_sockets', playerId, socketId);
|
||||
|
||||
// 获取当前位置信息
|
||||
const existingInfo = await this.redisService.hget('plaza:player_positions', playerId);
|
||||
return JSON.parse(existingInfo);
|
||||
}
|
||||
|
||||
// 4. 为玩家分配初始位置(广场中心附近随机位置)
|
||||
const initialPosition = this.generateInitialPosition();
|
||||
|
||||
const playerInfo: PlazaPlayerInfo = {
|
||||
playerId: player.id,
|
||||
nickname: player.nickname,
|
||||
avatarSkin: player.avatarSkin,
|
||||
position: initialPosition,
|
||||
lastUpdate: new Date(),
|
||||
socketId
|
||||
};
|
||||
|
||||
// 5. 更新Redis中的玩家状态
|
||||
await Promise.all([
|
||||
this.redisService.sadd('plaza:online_players', playerId),
|
||||
this.redisService.hset('plaza:player_positions', playerId, JSON.stringify(playerInfo)),
|
||||
this.redisService.hset('plaza:player_sockets', playerId, socketId),
|
||||
this.redisService.expire('plaza:player_positions', 3600), // 1小时过期
|
||||
this.redisService.expire('plaza:player_sockets', 3600)
|
||||
]);
|
||||
|
||||
// 6. 向其他玩家广播新玩家进入消息
|
||||
this.webSocketGateway.broadcastToPlaza('player_entered', {
|
||||
playerId: player.id,
|
||||
nickname: player.nickname,
|
||||
avatarSkin: player.avatarSkin,
|
||||
position: initialPosition
|
||||
}, socketId); // 排除新进入的玩家
|
||||
|
||||
// 7. 向新玩家发送当前广场状态
|
||||
const allPlayers = await this.getAllPlazaPlayers();
|
||||
this.webSocketGateway.sendToPlayer(socketId, 'plaza_state', {
|
||||
players: allPlayers.filter(p => p.playerId !== playerId),
|
||||
totalPlayers: allPlayers.length
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.info('玩家成功进入广场', {
|
||||
operation: 'enterPlaza',
|
||||
playerId,
|
||||
socketId,
|
||||
position: initialPosition,
|
||||
totalPlayers: currentPlayers + 1,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return playerInfo;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof UnauthorizedException ||
|
||||
error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error('玩家进入广场系统异常', {
|
||||
operation: 'enterPlaza',
|
||||
playerId,
|
||||
socketId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
throw new InternalServerErrorException('进入广场失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成初始位置
|
||||
*
|
||||
* 功能描述:
|
||||
* 在广场中心附近生成随机的初始位置,避免玩家重叠
|
||||
*
|
||||
* @returns Position 包含x、y坐标的位置对象
|
||||
* @private
|
||||
*/
|
||||
private generateInitialPosition(): Position {
|
||||
// 广场中心坐标 (400, 300),在半径100像素范围内随机分配
|
||||
const centerX = 400;
|
||||
const centerY = 300;
|
||||
const radius = 100;
|
||||
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
const distance = Math.random() * radius;
|
||||
|
||||
const x = Math.round(centerX + distance * Math.cos(angle));
|
||||
const y = Math.round(centerY + distance * Math.sin(angle));
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有广场玩家信息
|
||||
*
|
||||
* @returns Promise<PlazaPlayerInfo[]> 广场中所有玩家的信息列表
|
||||
* @private
|
||||
*/
|
||||
private async getAllPlazaPlayers(): Promise<PlazaPlayerInfo[]> {
|
||||
try {
|
||||
const playerIds = await this.redisService.smembers('plaza:online_players');
|
||||
const playerInfos = await Promise.all(
|
||||
playerIds.map(async (playerId) => {
|
||||
const info = await this.redisService.hget('plaza:player_positions', playerId);
|
||||
return info ? JSON.parse(info) : null;
|
||||
})
|
||||
);
|
||||
|
||||
return playerInfos.filter(info => info !== null);
|
||||
} catch (error) {
|
||||
this.logger.error('获取广场玩家列表失败', {
|
||||
operation: 'getAllPlazaPlayers',
|
||||
error: error.message
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、工具和配置
|
||||
|
||||
### 7.1 推荐的开发工具
|
||||
|
||||
| 工具 | 用途 | 配置说明 |
|
||||
|------|------|---------|
|
||||
| **ESLint** | 代码规范检查 | 配置注释规范检查规则 |
|
||||
| **Prettier** | 代码格式化 | 统一代码格式 |
|
||||
| **TSDoc** | 文档生成 | 从注释生成API文档 |
|
||||
| **SonarQube** | 代码质量分析 | 检查代码覆盖率和复杂度 |
|
||||
|
||||
### 7.2 日志配置示例
|
||||
|
||||
```typescript
|
||||
// logger.config.ts
|
||||
export const loggerConfig = {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error'
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log'
|
||||
})
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、总结
|
||||
|
||||
本规范文档定义了后端开发的核心要求:
|
||||
|
||||
1. **完整的注释体系**:模块、类、方法三级注释,确保代码可读性
|
||||
2. **全面的业务逻辑**:考虑所有可能情况,实现防御性编程
|
||||
3. **规范的日志记录**:关键操作必须记录,便于问题排查和系统监控
|
||||
4. **统一的异常处理**:分类处理不同类型异常,提供友好的用户体验
|
||||
|
||||
遵循本规范将显著提高代码质量、系统稳定性和团队协作效率。所有开发人员必须严格按照本规范进行开发,代码审查时将重点检查这些方面的实现。
|
||||
@@ -1,311 +0,0 @@
|
||||
const io = require('socket.io-client');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
console.log('🔍 全面WebSocket连接诊断');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 1. 测试基础网络连接
|
||||
async function testBasicConnection() {
|
||||
console.log('\n1️⃣ 测试基础HTTPS连接...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'whaletownend.xinghangee.icu',
|
||||
port: 443,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 10000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(`✅ HTTPS连接成功 - 状态码: ${res.statusCode}`);
|
||||
console.log(`📋 服务器: ${res.headers.server || '未知'}`);
|
||||
resolve({ success: true, statusCode: res.statusCode });
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(`❌ HTTPS连接失败: ${error.message}`);
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log('❌ HTTPS连接超时');
|
||||
req.destroy();
|
||||
resolve({ success: false, error: 'timeout' });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 测试本地服务器
|
||||
async function testLocalServer() {
|
||||
console.log('\n2️⃣ 测试本地服务器...');
|
||||
|
||||
const testPaths = [
|
||||
'http://localhost:3000/',
|
||||
'http://localhost:3000/socket.io/?EIO=4&transport=polling'
|
||||
];
|
||||
|
||||
for (const url of testPaths) {
|
||||
console.log(`🧪 测试: ${url}`);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const urlObj = new URL(url);
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(` 状态码: ${res.statusCode}`);
|
||||
if (res.statusCode === 200) {
|
||||
console.log(' ✅ 本地服务器正常');
|
||||
} else {
|
||||
console.log(` ⚠️ 本地服务器响应: ${res.statusCode}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(` ❌ 本地服务器连接失败: ${error.message}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(' ❌ 本地服务器超时');
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 测试远程Socket.IO路径
|
||||
async function testRemoteSocketIO() {
|
||||
console.log('\n3️⃣ 测试远程Socket.IO路径...');
|
||||
|
||||
const testPaths = [
|
||||
'/socket.io/?EIO=4&transport=polling',
|
||||
'/game/socket.io/?EIO=4&transport=polling',
|
||||
'/socket.io/?transport=polling',
|
||||
'/api/socket.io/?EIO=4&transport=polling'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const path of testPaths) {
|
||||
console.log(`🧪 测试路径: ${path}`);
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'whaletownend.xinghangee.icu',
|
||||
port: 443,
|
||||
path: path,
|
||||
method: 'GET',
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'User-Agent': 'socket.io-diagnosis'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(` 状态码: ${res.statusCode}`);
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(' ✅ 路径可用');
|
||||
console.log(` 📄 响应: ${data.substring(0, 50)}...`);
|
||||
} else {
|
||||
console.log(` ❌ 路径不可用: ${res.statusCode}`);
|
||||
}
|
||||
resolve({ path, statusCode: res.statusCode, success: res.statusCode === 200 });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(` ❌ 请求失败: ${error.message}`);
|
||||
resolve({ path, error: error.message, success: false });
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(' ❌ 请求超时');
|
||||
req.destroy();
|
||||
resolve({ path, error: 'timeout', success: false });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 4. 测试Socket.IO客户端连接
|
||||
async function testSocketIOClient() {
|
||||
console.log('\n4️⃣ 测试Socket.IO客户端连接...');
|
||||
|
||||
const configs = [
|
||||
{
|
||||
name: 'HTTPS + 所有传输方式',
|
||||
url: 'https://whaletownend.xinghangee.icu',
|
||||
options: { transports: ['websocket', 'polling'], timeout: 10000 }
|
||||
},
|
||||
{
|
||||
name: 'HTTPS + 仅Polling',
|
||||
url: 'https://whaletownend.xinghangee.icu',
|
||||
options: { transports: ['polling'], timeout: 10000 }
|
||||
},
|
||||
{
|
||||
name: 'HTTPS + /game namespace',
|
||||
url: 'https://whaletownend.xinghangee.icu/game',
|
||||
options: { transports: ['polling'], timeout: 10000 }
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const config of configs) {
|
||||
console.log(`🧪 测试: ${config.name}`);
|
||||
console.log(` URL: ${config.url}`);
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const socket = io(config.url, config.options);
|
||||
let resolved = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
socket.disconnect();
|
||||
console.log(' ❌ 连接超时');
|
||||
resolve({ success: false, error: 'timeout' });
|
||||
}
|
||||
}, config.options.timeout);
|
||||
|
||||
socket.on('connect', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(' ✅ 连接成功');
|
||||
console.log(` 📡 Socket ID: ${socket.id}`);
|
||||
console.log(` 🚀 传输方式: ${socket.io.engine.transport.name}`);
|
||||
socket.disconnect();
|
||||
resolve({ success: true, transport: socket.io.engine.transport.name });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
console.log(` ❌ 连接失败: ${error.message}`);
|
||||
resolve({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
results.push({ config: config.name, ...result });
|
||||
|
||||
// 等待1秒再测试下一个
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 5. 检查DNS解析
|
||||
async function testDNS() {
|
||||
console.log('\n5️⃣ 检查DNS解析...');
|
||||
|
||||
const dns = require('dns');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup('whaletownend.xinghangee.icu', (err, address, family) => {
|
||||
if (err) {
|
||||
console.log(`❌ DNS解析失败: ${err.message}`);
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
console.log(`✅ DNS解析成功: ${address} (IPv${family})`);
|
||||
resolve({ success: true, address, family });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 主诊断函数
|
||||
async function runFullDiagnosis() {
|
||||
console.log('开始全面诊断...\n');
|
||||
|
||||
try {
|
||||
const dnsResult = await testDNS();
|
||||
const basicResult = await testBasicConnection();
|
||||
await testLocalServer();
|
||||
const socketIOPaths = await testRemoteSocketIO();
|
||||
const clientResults = await testSocketIOClient();
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 诊断结果汇总');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log(`1. DNS解析: ${dnsResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||
if (dnsResult.address) {
|
||||
console.log(` IP地址: ${dnsResult.address}`);
|
||||
}
|
||||
|
||||
console.log(`2. HTTPS连接: ${basicResult.success ? '✅ 正常' : '❌ 失败'}`);
|
||||
if (basicResult.error) {
|
||||
console.log(` 错误: ${basicResult.error}`);
|
||||
}
|
||||
|
||||
const workingPaths = socketIOPaths.filter(r => r.success);
|
||||
console.log(`3. Socket.IO路径: ${workingPaths.length}/${socketIOPaths.length} 个可用`);
|
||||
workingPaths.forEach(p => {
|
||||
console.log(` ✅ ${p.path}`);
|
||||
});
|
||||
|
||||
const workingClients = clientResults.filter(r => r.success);
|
||||
console.log(`4. Socket.IO客户端: ${workingClients.length}/${clientResults.length} 个成功`);
|
||||
workingClients.forEach(c => {
|
||||
console.log(` ✅ ${c.config} (${c.transport})`);
|
||||
});
|
||||
|
||||
console.log('\n💡 建议:');
|
||||
|
||||
if (!dnsResult.success) {
|
||||
console.log('❌ DNS解析失败 - 检查域名配置');
|
||||
} else if (!basicResult.success) {
|
||||
console.log('❌ 基础HTTPS连接失败 - 检查服务器状态和防火墙');
|
||||
} else if (workingPaths.length === 0) {
|
||||
console.log('❌ 所有Socket.IO路径都不可用 - 检查nginx配置和后端服务');
|
||||
} else if (workingClients.length === 0) {
|
||||
console.log('❌ Socket.IO客户端无法连接 - 可能是CORS或协议问题');
|
||||
} else {
|
||||
console.log('✅ 部分功能正常 - 使用可用的配置继续开发');
|
||||
|
||||
if (workingClients.length > 0) {
|
||||
const bestConfig = workingClients.find(c => c.transport === 'websocket') || workingClients[0];
|
||||
console.log(`💡 推荐使用: ${bestConfig.config}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('诊断过程中发生错误:', error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runFullDiagnosis();
|
||||
367
src/business/zulip/controllers/chat.controller.ts
Normal file
367
src/business/zulip/controllers/chat.controller.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 聊天相关的 REST API 控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供聊天消息的 REST API 接口
|
||||
* - 获取聊天历史记录
|
||||
* - 查看系统状态和统计信息
|
||||
* - 管理 WebSocket 连接状态
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-07
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { ZulipService } from '../zulip.service';
|
||||
import { ZulipWebSocketGateway } from '../zulip_websocket.gateway';
|
||||
import {
|
||||
SendChatMessageDto,
|
||||
ChatMessageResponseDto,
|
||||
GetChatHistoryDto,
|
||||
ChatHistoryResponseDto,
|
||||
SystemStatusResponseDto,
|
||||
} from '../dto/chat.dto';
|
||||
|
||||
@ApiTags('chat')
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(
|
||||
private readonly zulipService: ZulipService,
|
||||
private readonly websocketGateway: ZulipWebSocketGateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发送聊天消息(REST API 方式)
|
||||
*
|
||||
* 注意:这是 WebSocket 消息发送的 REST API 替代方案
|
||||
* 推荐使用 WebSocket 接口以获得更好的实时性
|
||||
*/
|
||||
@Post('send')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '发送聊天消息',
|
||||
description: '通过 REST API 发送聊天消息到 Zulip。注意:推荐使用 WebSocket 接口以获得更好的实时性。'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '消息发送成功',
|
||||
type: ChatMessageResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: '未授权访问',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: '服务器内部错误',
|
||||
})
|
||||
async sendMessage(
|
||||
@Body() sendMessageDto: SendChatMessageDto,
|
||||
): Promise<ChatMessageResponseDto> {
|
||||
this.logger.log('收到REST API聊天消息发送请求', {
|
||||
operation: 'sendMessage',
|
||||
content: sendMessageDto.content.substring(0, 50),
|
||||
scope: sendMessageDto.scope,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 注意:这里需要一个有效的 socketId,但 REST API 没有 WebSocket 连接
|
||||
// 这是一个限制,实际使用中应该通过 WebSocket 发送消息
|
||||
throw new HttpException(
|
||||
'聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:ws://localhost:3000/game',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('REST API消息发送失败', {
|
||||
operation: 'sendMessage',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
'消息发送失败,请稍后重试',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史记录
|
||||
*/
|
||||
@Get('history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: '获取聊天历史记录',
|
||||
description: '获取指定地图或全局的聊天历史记录'
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'mapId',
|
||||
required: false,
|
||||
description: '地图ID,不指定则获取全局消息',
|
||||
example: 'whale_port'
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: '消息数量限制',
|
||||
example: 50
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'offset',
|
||||
required: false,
|
||||
description: '偏移量(分页用)',
|
||||
example: 0
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取聊天历史成功',
|
||||
type: ChatHistoryResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: '未授权访问',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: '服务器内部错误',
|
||||
})
|
||||
async getChatHistory(
|
||||
@Query() query: GetChatHistoryDto,
|
||||
): Promise<ChatHistoryResponseDto> {
|
||||
this.logger.log('获取聊天历史记录', {
|
||||
operation: 'getChatHistory',
|
||||
mapId: query.mapId,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 注意:这里需要实现从 Zulip 获取消息历史的逻辑
|
||||
// 目前返回模拟数据
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'Player_123',
|
||||
content: '大家好!我刚进入游戏',
|
||||
scope: 'local',
|
||||
mapId: query.mapId || 'whale_port',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
streamName: 'Whale Port',
|
||||
topicName: 'Game Chat',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: 'Player_456',
|
||||
content: '欢迎新玩家!',
|
||||
scope: 'local',
|
||||
mapId: query.mapId || 'whale_port',
|
||||
timestamp: new Date(Date.now() - 1800000).toISOString(),
|
||||
streamName: 'Whale Port',
|
||||
topicName: 'Game Chat',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: mockMessages.slice(query.offset || 0, (query.offset || 0) + (query.limit || 50)),
|
||||
total: mockMessages.length,
|
||||
count: Math.min(mockMessages.length - (query.offset || 0), query.limit || 50),
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取聊天历史失败', {
|
||||
operation: 'getChatHistory',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
'获取聊天历史失败,请稍后重试',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
*/
|
||||
@Get('status')
|
||||
@ApiOperation({
|
||||
summary: '获取聊天系统状态',
|
||||
description: '获取 WebSocket 连接状态、Zulip 集成状态等系统信息'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取系统状态成功',
|
||||
type: SystemStatusResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 500,
|
||||
description: '服务器内部错误',
|
||||
})
|
||||
async getSystemStatus(): Promise<SystemStatusResponseDto> {
|
||||
this.logger.log('获取系统状态', {
|
||||
operation: 'getSystemStatus',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取 WebSocket 连接状态
|
||||
const totalConnections = await this.websocketGateway.getConnectionCount();
|
||||
const authenticatedConnections = await this.websocketGateway.getAuthenticatedConnectionCount();
|
||||
|
||||
// 获取内存使用情况
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const memoryUsedMB = (memoryUsage.heapUsed / 1024 / 1024).toFixed(1);
|
||||
const memoryTotalMB = (memoryUsage.heapTotal / 1024 / 1024).toFixed(1);
|
||||
const memoryPercentage = ((memoryUsage.heapUsed / memoryUsage.heapTotal) * 100);
|
||||
|
||||
return {
|
||||
websocket: {
|
||||
totalConnections,
|
||||
authenticatedConnections,
|
||||
activeSessions: authenticatedConnections, // 简化处理
|
||||
mapPlayerCounts: {
|
||||
'whale_port': Math.floor(authenticatedConnections * 0.4),
|
||||
'pumpkin_valley': Math.floor(authenticatedConnections * 0.3),
|
||||
'novice_village': Math.floor(authenticatedConnections * 0.3),
|
||||
},
|
||||
},
|
||||
zulip: {
|
||||
serverConnected: true, // 需要实际检查
|
||||
serverVersion: '11.4',
|
||||
botAccountActive: true,
|
||||
availableStreams: 12,
|
||||
gameStreams: ['Whale Port', 'Pumpkin Valley', 'Novice Village'],
|
||||
recentMessageCount: 156, // 需要从实际数据获取
|
||||
},
|
||||
uptime: Math.floor(process.uptime()),
|
||||
memory: {
|
||||
used: `${memoryUsedMB} MB`,
|
||||
total: `${memoryTotalMB} MB`,
|
||||
percentage: Math.round(memoryPercentage * 100) / 100,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('获取系统状态失败', {
|
||||
operation: 'getSystemStatus',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
'获取系统状态失败,请稍后重试',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 连接信息
|
||||
*/
|
||||
@Get('websocket/info')
|
||||
@ApiOperation({
|
||||
summary: '获取 WebSocket 连接信息',
|
||||
description: '获取 WebSocket 连接的详细信息,包括连接地址、协议等'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取连接信息成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
websocketUrl: {
|
||||
type: 'string',
|
||||
example: 'ws://localhost:3000/game',
|
||||
description: 'WebSocket 连接地址'
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
example: '/game',
|
||||
description: 'WebSocket 命名空间'
|
||||
},
|
||||
supportedEvents: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['login', 'chat', 'position_update'],
|
||||
description: '支持的事件类型'
|
||||
},
|
||||
authRequired: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
description: '是否需要认证'
|
||||
},
|
||||
documentation: {
|
||||
type: 'string',
|
||||
example: 'https://docs.example.com/websocket',
|
||||
description: '文档链接'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
async getWebSocketInfo() {
|
||||
return {
|
||||
websocketUrl: 'ws://localhost:3000/game',
|
||||
namespace: '/game',
|
||||
supportedEvents: [
|
||||
'login', // 用户登录
|
||||
'chat', // 发送聊天消息
|
||||
'position_update', // 位置更新
|
||||
],
|
||||
supportedResponses: [
|
||||
'login_success', // 登录成功
|
||||
'login_error', // 登录失败
|
||||
'chat_sent', // 消息发送成功
|
||||
'chat_error', // 消息发送失败
|
||||
'chat_render', // 接收到聊天消息
|
||||
],
|
||||
authRequired: true,
|
||||
tokenType: 'JWT',
|
||||
tokenFormat: {
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
type: 'access',
|
||||
requiredFields: ['sub', 'username', 'email', 'role']
|
||||
},
|
||||
documentation: '/api-docs',
|
||||
};
|
||||
}
|
||||
}
|
||||
421
src/business/zulip/controllers/websocket-docs.controller.ts
Normal file
421
src/business/zulip/controllers/websocket-docs.controller.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* WebSocket API 文档控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供 WebSocket API 的详细文档
|
||||
* - 展示消息格式和事件类型
|
||||
* - 提供连接示例和测试工具
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-07
|
||||
*/
|
||||
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('chat')
|
||||
@Controller('websocket')
|
||||
export class WebSocketDocsController {
|
||||
|
||||
/**
|
||||
* 获取 WebSocket API 文档
|
||||
*/
|
||||
@Get('docs')
|
||||
@ApiOperation({
|
||||
summary: 'WebSocket API 文档',
|
||||
description: '获取 WebSocket 连接和消息格式的详细文档'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'WebSocket API 文档',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connection: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
example: 'ws://localhost:3000/game',
|
||||
description: 'WebSocket 连接地址'
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
example: '/game',
|
||||
description: 'Socket.IO 命名空间'
|
||||
},
|
||||
transports: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['websocket', 'polling'],
|
||||
description: '支持的传输协议'
|
||||
}
|
||||
}
|
||||
},
|
||||
authentication: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
required: {
|
||||
type: 'boolean',
|
||||
example: true,
|
||||
description: '是否需要认证'
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
example: 'JWT Token',
|
||||
description: '认证方式'
|
||||
},
|
||||
tokenFormat: {
|
||||
type: 'object',
|
||||
description: 'JWT Token 格式要求'
|
||||
}
|
||||
}
|
||||
},
|
||||
events: {
|
||||
type: 'object',
|
||||
description: '支持的事件和消息格式'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
getWebSocketDocs() {
|
||||
return {
|
||||
connection: {
|
||||
url: 'ws://localhost:3000/game',
|
||||
namespace: '/game',
|
||||
transports: ['websocket', 'polling'],
|
||||
options: {
|
||||
timeout: 20000,
|
||||
forceNew: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 1000
|
||||
}
|
||||
},
|
||||
authentication: {
|
||||
required: true,
|
||||
method: 'JWT Token',
|
||||
tokenFormat: {
|
||||
issuer: 'whale-town',
|
||||
audience: 'whale-town-users',
|
||||
type: 'access',
|
||||
requiredFields: ['sub', 'username', 'email', 'role'],
|
||||
example: {
|
||||
sub: 'user_123',
|
||||
username: 'player_name',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
type: 'access',
|
||||
aud: 'whale-town-users',
|
||||
iss: 'whale-town',
|
||||
iat: 1767768599,
|
||||
exp: 1768373399
|
||||
}
|
||||
}
|
||||
},
|
||||
events: {
|
||||
clientToServer: {
|
||||
login: {
|
||||
description: '用户登录',
|
||||
format: {
|
||||
type: 'login',
|
||||
token: 'JWT_TOKEN_HERE'
|
||||
},
|
||||
example: {
|
||||
type: 'login',
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
},
|
||||
responses: ['login_success', 'login_error']
|
||||
},
|
||||
chat: {
|
||||
description: '发送聊天消息',
|
||||
format: {
|
||||
t: 'chat',
|
||||
content: 'string',
|
||||
scope: 'local | global'
|
||||
},
|
||||
example: {
|
||||
t: 'chat',
|
||||
content: '大家好!我刚进入游戏',
|
||||
scope: 'local'
|
||||
},
|
||||
responses: ['chat_sent', 'chat_error']
|
||||
},
|
||||
position_update: {
|
||||
description: '更新玩家位置',
|
||||
format: {
|
||||
t: 'position',
|
||||
x: 'number',
|
||||
y: 'number',
|
||||
mapId: 'string'
|
||||
},
|
||||
example: {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'whale_port'
|
||||
},
|
||||
responses: []
|
||||
}
|
||||
},
|
||||
serverToClient: {
|
||||
login_success: {
|
||||
description: '登录成功响应',
|
||||
format: {
|
||||
t: 'login_success',
|
||||
sessionId: 'string',
|
||||
userId: 'string',
|
||||
username: 'string',
|
||||
currentMap: 'string'
|
||||
},
|
||||
example: {
|
||||
t: 'login_success',
|
||||
sessionId: '89aff162-52d9-484e-9a35-036ba63a2280',
|
||||
userId: 'user_123',
|
||||
username: 'Player_123',
|
||||
currentMap: 'whale_port'
|
||||
}
|
||||
},
|
||||
login_error: {
|
||||
description: '登录失败响应',
|
||||
format: {
|
||||
t: 'login_error',
|
||||
message: 'string'
|
||||
},
|
||||
example: {
|
||||
t: 'login_error',
|
||||
message: 'Token验证失败'
|
||||
}
|
||||
},
|
||||
chat_sent: {
|
||||
description: '消息发送成功确认',
|
||||
format: {
|
||||
t: 'chat_sent',
|
||||
messageId: 'number',
|
||||
message: 'string'
|
||||
},
|
||||
example: {
|
||||
t: 'chat_sent',
|
||||
messageId: 137,
|
||||
message: '消息发送成功'
|
||||
}
|
||||
},
|
||||
chat_error: {
|
||||
description: '消息发送失败',
|
||||
format: {
|
||||
t: 'chat_error',
|
||||
message: 'string'
|
||||
},
|
||||
example: {
|
||||
t: 'chat_error',
|
||||
message: '消息内容不能为空'
|
||||
}
|
||||
},
|
||||
chat_render: {
|
||||
description: '接收到聊天消息',
|
||||
format: {
|
||||
t: 'chat_render',
|
||||
from: 'string',
|
||||
txt: 'string',
|
||||
bubble: 'boolean'
|
||||
},
|
||||
example: {
|
||||
t: 'chat_render',
|
||||
from: 'Player_456',
|
||||
txt: '欢迎新玩家!',
|
||||
bubble: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
maps: {
|
||||
whale_port: {
|
||||
name: 'Whale Port',
|
||||
displayName: '鲸鱼港',
|
||||
zulipStream: 'Whale Port',
|
||||
description: '游戏的主要港口区域'
|
||||
},
|
||||
pumpkin_valley: {
|
||||
name: 'Pumpkin Valley',
|
||||
displayName: '南瓜谷',
|
||||
zulipStream: 'Pumpkin Valley',
|
||||
description: '充满南瓜的神秘山谷'
|
||||
},
|
||||
novice_village: {
|
||||
name: 'Novice Village',
|
||||
displayName: '新手村',
|
||||
zulipStream: 'Novice Village',
|
||||
description: '新玩家的起始区域'
|
||||
}
|
||||
},
|
||||
examples: {
|
||||
javascript: {
|
||||
connection: `
|
||||
// 使用 Socket.IO 客户端连接
|
||||
const io = require('socket.io-client');
|
||||
|
||||
const socket = io('ws://localhost:3000/game', {
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 20000,
|
||||
forceNew: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('连接成功:', socket.id);
|
||||
|
||||
// 发送登录消息
|
||||
socket.emit('login', {
|
||||
type: 'login',
|
||||
token: 'YOUR_JWT_TOKEN_HERE'
|
||||
});
|
||||
});
|
||||
|
||||
// 登录成功
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('登录成功:', data);
|
||||
|
||||
// 发送聊天消息
|
||||
socket.emit('chat', {
|
||||
t: 'chat',
|
||||
content: '大家好!',
|
||||
scope: 'local'
|
||||
});
|
||||
});
|
||||
|
||||
// 接收聊天消息
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('收到消息:', data.from, '说:', data.txt);
|
||||
});
|
||||
`,
|
||||
godot: `
|
||||
# Godot WebSocket 客户端示例
|
||||
extends Node
|
||||
|
||||
var socket = WebSocketClient.new()
|
||||
var url = "ws://localhost:3000/game"
|
||||
|
||||
func _ready():
|
||||
socket.connect("connection_closed", self, "_closed")
|
||||
socket.connect("connection_error", self, "_error")
|
||||
socket.connect("connection_established", self, "_connected")
|
||||
socket.connect("data_received", self, "_on_data")
|
||||
|
||||
var err = socket.connect_to_url(url)
|
||||
if err != OK:
|
||||
print("连接失败")
|
||||
|
||||
func _connected(protocol):
|
||||
print("WebSocket 连接成功")
|
||||
# 发送登录消息
|
||||
var login_msg = {
|
||||
"type": "login",
|
||||
"token": "YOUR_JWT_TOKEN_HERE"
|
||||
}
|
||||
socket.get_peer(1).put_packet(JSON.print(login_msg).to_utf8())
|
||||
|
||||
func _on_data():
|
||||
var packet = socket.get_peer(1).get_packet()
|
||||
var message = JSON.parse(packet.get_string_from_utf8())
|
||||
print("收到消息: ", message.result)
|
||||
`
|
||||
}
|
||||
},
|
||||
troubleshooting: {
|
||||
commonIssues: [
|
||||
{
|
||||
issue: 'Token验证失败',
|
||||
solution: '确保JWT Token包含正确的issuer、audience和type字段'
|
||||
},
|
||||
{
|
||||
issue: '连接超时',
|
||||
solution: '检查服务器是否运行,防火墙设置是否正确'
|
||||
},
|
||||
{
|
||||
issue: '消息发送失败',
|
||||
solution: '确保已经成功登录,消息内容不为空'
|
||||
}
|
||||
],
|
||||
testTools: [
|
||||
{
|
||||
name: 'WebSocket King',
|
||||
url: 'https://websocketking.com/',
|
||||
description: '在线WebSocket测试工具'
|
||||
},
|
||||
{
|
||||
name: 'Postman',
|
||||
description: 'Postman也支持WebSocket连接测试'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息格式示例
|
||||
*/
|
||||
@Get('message-examples')
|
||||
@ApiOperation({
|
||||
summary: '消息格式示例',
|
||||
description: '获取各种 WebSocket 消息的格式示例'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '消息格式示例',
|
||||
})
|
||||
getMessageExamples() {
|
||||
return {
|
||||
login: {
|
||||
request: {
|
||||
type: 'login',
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0X3VzZXJfMTIzIiwidXNlcm5hbWUiOiJ0ZXN0X3VzZXIiLCJlbWFpbCI6InRlc3RfdXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJ1c2VyIiwidHlwZSI6ImFjY2VzcyIsImF1ZCI6IndoYWxlLXRvd24tdXNlcnMiLCJpc3MiOiJ3aGFsZS10b3duIiwiaWF0IjoxNzY3NzY4NTk5LCJleHAiOjE3NjgzNzMzOTl9.Mq3YccSV_pMKxIAbeNRAUws1j7doqFqvlSv4Z9DhGjI'
|
||||
},
|
||||
successResponse: {
|
||||
t: 'login_success',
|
||||
sessionId: '89aff162-52d9-484e-9a35-036ba63a2280',
|
||||
userId: 'test_user_123',
|
||||
username: 'test_user',
|
||||
currentMap: 'whale_port'
|
||||
},
|
||||
errorResponse: {
|
||||
t: 'login_error',
|
||||
message: 'Token验证失败'
|
||||
}
|
||||
},
|
||||
chat: {
|
||||
request: {
|
||||
t: 'chat',
|
||||
content: '大家好!我刚进入游戏',
|
||||
scope: 'local'
|
||||
},
|
||||
successResponse: {
|
||||
t: 'chat_sent',
|
||||
messageId: 137,
|
||||
message: '消息发送成功'
|
||||
},
|
||||
errorResponse: {
|
||||
t: 'chat_error',
|
||||
message: '消息内容不能为空'
|
||||
},
|
||||
incomingMessage: {
|
||||
t: 'chat_render',
|
||||
from: 'Player_456',
|
||||
txt: '欢迎新玩家!',
|
||||
bubble: true
|
||||
}
|
||||
},
|
||||
position: {
|
||||
request: {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
313
src/business/zulip/dto/chat.dto.ts
Normal file
313
src/business/zulip/dto/chat.dto.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 聊天相关的 DTO 定义
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-07
|
||||
*/
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsBoolean, IsEnum, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* 发送聊天消息请求 DTO
|
||||
*/
|
||||
export class SendChatMessageDto {
|
||||
@ApiProperty({
|
||||
description: '消息内容',
|
||||
example: '大家好!我刚进入游戏',
|
||||
maxLength: 1000
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息范围',
|
||||
example: 'local',
|
||||
enum: ['local', 'global'],
|
||||
default: 'local'
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scope: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '地图ID(可选,用于地图相关消息)',
|
||||
example: 'whale_port'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mapId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息响应 DTO
|
||||
*/
|
||||
export class ChatMessageResponseDto {
|
||||
@ApiProperty({
|
||||
description: '是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息ID',
|
||||
example: 12345
|
||||
})
|
||||
messageId: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '响应消息',
|
||||
example: '消息发送成功'
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '错误信息(失败时)',
|
||||
example: '消息内容不能为空'
|
||||
})
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史请求 DTO
|
||||
*/
|
||||
export class GetChatHistoryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '地图ID(可选)',
|
||||
example: 'whale_port'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mapId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '消息数量限制',
|
||||
example: 50,
|
||||
default: 50,
|
||||
minimum: 1,
|
||||
maximum: 100
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
limit?: number = 50;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '偏移量(分页用)',
|
||||
example: 0,
|
||||
default: 0,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
offset?: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息信息 DTO
|
||||
*/
|
||||
export class ChatMessageInfoDto {
|
||||
@ApiProperty({
|
||||
description: '消息ID',
|
||||
example: 12345
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '发送者用户名',
|
||||
example: 'Player_123'
|
||||
})
|
||||
sender: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息内容',
|
||||
example: '大家好!'
|
||||
})
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息范围',
|
||||
example: 'local'
|
||||
})
|
||||
scope: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '地图ID',
|
||||
example: 'whale_port'
|
||||
})
|
||||
mapId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '发送时间',
|
||||
example: '2025-01-07T14:30:00.000Z'
|
||||
})
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip Stream 名称',
|
||||
example: 'Whale Port'
|
||||
})
|
||||
streamName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip Topic 名称',
|
||||
example: 'Game Chat'
|
||||
})
|
||||
topicName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天历史响应 DTO
|
||||
*/
|
||||
export class ChatHistoryResponseDto {
|
||||
@ApiProperty({
|
||||
description: '是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '消息列表',
|
||||
type: [ChatMessageInfoDto]
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageInfoDto)
|
||||
messages: ChatMessageInfoDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '总消息数',
|
||||
example: 150
|
||||
})
|
||||
total: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前页消息数',
|
||||
example: 50
|
||||
})
|
||||
count: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '错误信息(失败时)',
|
||||
example: '获取消息历史失败'
|
||||
})
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 连接状态 DTO
|
||||
*/
|
||||
export class WebSocketStatusDto {
|
||||
@ApiProperty({
|
||||
description: '总连接数',
|
||||
example: 25
|
||||
})
|
||||
totalConnections: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '已认证连接数',
|
||||
example: 20
|
||||
})
|
||||
authenticatedConnections: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '活跃会话数',
|
||||
example: 18
|
||||
})
|
||||
activeSessions: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '各地图在线人数',
|
||||
example: {
|
||||
'whale_port': 8,
|
||||
'pumpkin_valley': 5,
|
||||
'novice_village': 7
|
||||
}
|
||||
})
|
||||
mapPlayerCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zulip 集成状态 DTO
|
||||
*/
|
||||
export class ZulipIntegrationStatusDto {
|
||||
@ApiProperty({
|
||||
description: 'Zulip 服务器连接状态',
|
||||
example: true
|
||||
})
|
||||
serverConnected: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip 服务器版本',
|
||||
example: '11.4'
|
||||
})
|
||||
serverVersion: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '机器人账号状态',
|
||||
example: true
|
||||
})
|
||||
botAccountActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '可用 Stream 数量',
|
||||
example: 12
|
||||
})
|
||||
availableStreams: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '游戏相关 Stream 列表',
|
||||
example: ['Whale Port', 'Pumpkin Valley', 'Novice Village']
|
||||
})
|
||||
gameStreams: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '最近24小时消息数',
|
||||
example: 156
|
||||
})
|
||||
recentMessageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统状态响应 DTO
|
||||
*/
|
||||
export class SystemStatusResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'WebSocket 状态',
|
||||
type: WebSocketStatusDto
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => WebSocketStatusDto)
|
||||
websocket: WebSocketStatusDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Zulip 集成状态',
|
||||
type: ZulipIntegrationStatusDto
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => ZulipIntegrationStatusDto)
|
||||
zulip: ZulipIntegrationStatusDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '系统运行时间(秒)',
|
||||
example: 86400
|
||||
})
|
||||
uptime: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '内存使用情况',
|
||||
example: {
|
||||
used: '45.2 MB',
|
||||
total: '64.0 MB',
|
||||
percentage: 70.6
|
||||
}
|
||||
})
|
||||
memory: {
|
||||
used: string;
|
||||
total: string;
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,8 @@ import { SessionManagerService } from './services/session_manager.service';
|
||||
import { MessageFilterService } from './services/message_filter.service';
|
||||
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
|
||||
import { SessionCleanupService } from './services/session_cleanup.service';
|
||||
import { ChatController } from './controllers/chat.controller';
|
||||
import { WebSocketDocsController } from './controllers/websocket-docs.controller';
|
||||
import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||
@@ -82,7 +84,12 @@ import { AuthModule } from '../auth/auth.module';
|
||||
// WebSocket网关 - 处理游戏客户端WebSocket连接
|
||||
ZulipWebSocketGateway,
|
||||
],
|
||||
controllers: [],
|
||||
controllers: [
|
||||
// 聊天相关的REST API控制器
|
||||
ChatController,
|
||||
// WebSocket API文档控制器
|
||||
WebSocketDocsController,
|
||||
],
|
||||
exports: [
|
||||
// 导出主服务供其他模块使用
|
||||
ZulipService,
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
/**
|
||||
* 用户实体、DTO和服务的完整测试套件
|
||||
*
|
||||
* 功能:
|
||||
* - 测试Users实体的结构和装饰器
|
||||
* - 测试CreateUserDto的验证规则
|
||||
* - 测试UsersService的所有CRUD操作
|
||||
* - 验证数据类型和约束条件
|
||||
* 功能描述:
|
||||
* - 测试Users实体的结构和装饰器配置
|
||||
* - 测试CreateUserDto的数据验证规则和边界条件
|
||||
* - 测试UsersService的所有CRUD操作和业务逻辑
|
||||
* - 验证数据类型、约束条件和异常处理
|
||||
* - 确保服务层与数据库交互的正确性
|
||||
*
|
||||
* 测试覆盖范围:
|
||||
* - 实体字段映射和类型验证
|
||||
* - DTO数据验证和错误处理
|
||||
* - 服务方法的正常流程和异常流程
|
||||
* - 数据库操作的模拟和验证
|
||||
* - 业务规则和约束条件检查
|
||||
*
|
||||
* 测试策略:
|
||||
* - 单元测试:独立测试每个方法的功能
|
||||
* - 集成测试:测试DTO到Entity的完整流程
|
||||
* - 异常测试:验证各种错误情况的处理
|
||||
* - 边界测试:测试数据验证的边界条件
|
||||
*
|
||||
* 依赖模块:
|
||||
* - Jest: 测试框架和断言库
|
||||
* - NestJS Testing: 提供测试模块和依赖注入
|
||||
* - class-validator: DTO验证测试
|
||||
* - TypeORM: 数据库操作模拟
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-17
|
||||
*
|
||||
* @lastModified 2025-01-07 by moyin
|
||||
* @lastChange 添加完整的测试注释体系,增强测试覆盖率和方法测试
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
@@ -25,10 +47,21 @@ import { UsersService } from './users.service';
|
||||
|
||||
describe('Users Entity, DTO and Service Tests', () => {
|
||||
let service: UsersService;
|
||||
let repository: Repository<Users>;
|
||||
let module: TestingModule;
|
||||
|
||||
// 模拟的Repository方法
|
||||
/**
|
||||
* 模拟的TypeORM Repository方法
|
||||
*
|
||||
* 功能:模拟数据库操作,避免真实数据库依赖
|
||||
* 包含的方法:
|
||||
* - save: 保存实体到数据库
|
||||
* - find: 查询多个实体
|
||||
* - findOne: 查询单个实体
|
||||
* - delete: 删除实体
|
||||
* - softRemove: 软删除实体
|
||||
* - count: 统计实体数量
|
||||
* - createQueryBuilder: 创建查询构建器
|
||||
*/
|
||||
const mockRepository = {
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
@@ -39,22 +72,45 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
// 测试数据
|
||||
/**
|
||||
* 测试用的模拟用户数据
|
||||
*
|
||||
* 包含所有Users实体的字段:
|
||||
* - 基础信息:id, username, nickname
|
||||
* - 联系方式:email, phone
|
||||
* - 认证信息:password_hash, github_id
|
||||
* - 状态信息:role, status, email_verified
|
||||
* - 时间戳:created_at, updated_at
|
||||
* - 扩展信息:avatar_url
|
||||
*/
|
||||
const mockUser: Users = {
|
||||
id: BigInt(1),
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
email_verified: false,
|
||||
phone: '+8613800138000',
|
||||
password_hash: 'hashed_password',
|
||||
nickname: '测试用户',
|
||||
github_id: 'github_123',
|
||||
avatar_url: 'https://example.com/avatar.jpg',
|
||||
role: 1,
|
||||
email_verified: false,
|
||||
status: 'active' as any, // UserStatus.ACTIVE
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试用的创建用户DTO数据
|
||||
*
|
||||
* 包含创建用户所需的基本字段:
|
||||
* - 必填字段:username, nickname
|
||||
* - 可选字段:email, phone, password_hash, github_id, avatar_url, role
|
||||
*
|
||||
* 用于测试:
|
||||
* - 数据验证规则
|
||||
* - 用户创建流程
|
||||
* - DTO到Entity的转换
|
||||
*/
|
||||
const createUserDto: CreateUserDto = {
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
@@ -66,6 +122,17 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
role: 1
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试前置设置
|
||||
*
|
||||
* 功能:
|
||||
* - 创建测试模块和依赖注入容器
|
||||
* - 配置UsersService和模拟的Repository
|
||||
* - 初始化测试环境
|
||||
* - 清理之前测试的Mock状态
|
||||
*
|
||||
* 执行时机:每个测试用例执行前
|
||||
*/
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -78,17 +145,47 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
repository = module.get<Repository<Users>>(getRepositoryToken(Users));
|
||||
|
||||
// 清理所有mock
|
||||
// 清理所有mock状态,确保测试独立性
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试后置清理
|
||||
*
|
||||
* 功能:
|
||||
* - 关闭测试模块
|
||||
* - 释放资源和内存
|
||||
* - 防止测试间的状态污染
|
||||
*
|
||||
* 执行时机:每个测试用例执行后
|
||||
*/
|
||||
afterEach(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Users实体测试组
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证Users实体类的基本功能
|
||||
* - 测试实体字段的设置和获取
|
||||
* - 确保实体结构符合设计要求
|
||||
*
|
||||
* 测试内容:
|
||||
* - 实体实例化和属性赋值
|
||||
* - 字段类型和数据完整性
|
||||
* - TypeORM装饰器的正确配置
|
||||
*/
|
||||
describe('Users Entity Tests', () => {
|
||||
/**
|
||||
* 测试用户实体的基本创建和属性设置
|
||||
*
|
||||
* 验证点:
|
||||
* - 实体可以正常实例化
|
||||
* - 所有字段可以正确赋值
|
||||
* - 字段值可以正确读取
|
||||
*/
|
||||
it('应该正确创建用户实体实例', () => {
|
||||
const user = new Users();
|
||||
user.username = 'testuser';
|
||||
@@ -136,7 +233,31 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* CreateUserDto数据验证测试组
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证DTO的数据验证规则
|
||||
* - 测试各种输入数据的验证结果
|
||||
* - 确保数据完整性和业务规则
|
||||
*
|
||||
* 测试内容:
|
||||
* - 有效数据的验证通过
|
||||
* - 无效数据的验证失败
|
||||
* - 必填字段的验证
|
||||
* - 格式验证(邮箱、手机号等)
|
||||
* - 长度限制验证
|
||||
* - 数值范围验证
|
||||
*/
|
||||
describe('CreateUserDto Validation Tests', () => {
|
||||
/**
|
||||
* 测试有效数据的验证
|
||||
*
|
||||
* 验证点:
|
||||
* - 包含所有必填字段的数据应该通过验证
|
||||
* - 可选字段的数据格式正确
|
||||
* - 验证错误数组应该为空
|
||||
*/
|
||||
it('应该通过有效数据的验证', async () => {
|
||||
const validData = {
|
||||
username: 'testuser',
|
||||
@@ -155,6 +276,14 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试缺少必填字段时的验证失败
|
||||
*
|
||||
* 验证点:
|
||||
* - 缺少username和nickname时验证应该失败
|
||||
* - 验证错误数组包含对应的字段错误
|
||||
* - 错误信息准确指向缺失的字段
|
||||
*/
|
||||
it('应该拒绝缺少必填字段的数据', async () => {
|
||||
const invalidData = {
|
||||
email: 'test@example.com'
|
||||
@@ -173,6 +302,14 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
expect(nicknameError).toBeDefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试邮箱格式验证
|
||||
*
|
||||
* 验证点:
|
||||
* - 无效的邮箱格式应该被拒绝
|
||||
* - 验证错误指向email字段
|
||||
* - 错误信息提示格式不正确
|
||||
*/
|
||||
it('应该拒绝无效的邮箱格式', async () => {
|
||||
const invalidData = {
|
||||
username: 'testuser',
|
||||
@@ -215,11 +352,46 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* UsersService CRUD操作测试组
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证所有CRUD操作的正确性
|
||||
* - 测试业务逻辑和数据处理
|
||||
* - 确保异常情况的正确处理
|
||||
*
|
||||
* 测试内容:
|
||||
* - 创建操作:create, createWithDuplicateCheck, createBatch
|
||||
* - 查询操作:findAll, findOne, findByUsername, findByEmail, findByGithubId, findByRole, search
|
||||
* - 更新操作:update
|
||||
* - 删除操作:remove, softRemove
|
||||
* - 工具方法:count, exists
|
||||
*
|
||||
* 测试策略:
|
||||
* - 正常流程测试:验证方法的基本功能
|
||||
* - 异常流程测试:验证错误处理和异常抛出
|
||||
* - 边界条件测试:验证参数边界和特殊情况
|
||||
*/
|
||||
describe('UsersService CRUD Tests', () => {
|
||||
|
||||
/**
|
||||
* create()方法测试组
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证基础用户创建功能
|
||||
* - 测试数据验证和异常处理
|
||||
* - 确保数据库操作的正确性
|
||||
*/
|
||||
describe('create()', () => {
|
||||
/**
|
||||
* 测试成功创建用户的正常流程
|
||||
*
|
||||
* 验证点:
|
||||
* - Repository.save方法被正确调用
|
||||
* - 返回值与期望的用户数据一致
|
||||
* - 数据验证通过
|
||||
*/
|
||||
it('应该成功创建新用户', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(null); // 没有重复用户
|
||||
mockRepository.save.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.create(createUserDto);
|
||||
@@ -228,18 +400,17 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('应该在用户名重复时抛出ConflictException', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockUser); // 模拟用户名已存在
|
||||
|
||||
await expect(service.createWithDuplicateCheck(createUserDto)).rejects.toThrow(ConflictException);
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在数据验证失败时抛出BadRequestException', async () => {
|
||||
const invalidDto = { username: '', nickname: '' }; // 无效数据
|
||||
|
||||
await expect(service.create(invalidDto as CreateUserDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该在系统异常时抛出BadRequestException', async () => {
|
||||
mockRepository.save.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(service.create(createUserDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll()', () => {
|
||||
@@ -310,17 +481,126 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail()', () => {
|
||||
it('应该根据邮箱返回用户', async () => {
|
||||
describe('findByGithubId()', () => {
|
||||
it('应该根据GitHub ID返回用户', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findByEmail('test@example.com');
|
||||
const result = await service.findByGithubId('github_123');
|
||||
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: 'test@example.com' }
|
||||
where: { github_id: 'github_123' }
|
||||
});
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('应该在GitHub ID不存在时返回null', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findByGithubId('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWithDuplicateCheck()', () => {
|
||||
it('应该成功创建用户(带重复检查)', async () => {
|
||||
// 模拟所有唯一性检查都通过
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
mockRepository.save.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.createWithDuplicateCheck(createUserDto);
|
||||
|
||||
expect(mockRepository.findOne).toHaveBeenCalledTimes(4); // 检查用户名、邮箱、手机号、GitHub ID
|
||||
expect(mockRepository.save).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('应该在用户名重复时抛出ConflictException', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockUser); // 模拟用户名已存在
|
||||
|
||||
await expect(service.createWithDuplicateCheck(createUserDto)).rejects.toThrow(ConflictException);
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在邮箱重复时抛出ConflictException', async () => {
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 用户名检查通过
|
||||
.mockResolvedValueOnce(mockUser); // 邮箱已存在
|
||||
|
||||
await expect(service.createWithDuplicateCheck(createUserDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('应该在手机号重复时抛出ConflictException', async () => {
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 用户名检查通过
|
||||
.mockResolvedValueOnce(null) // 邮箱检查通过
|
||||
.mockResolvedValueOnce(mockUser); // 手机号已存在
|
||||
|
||||
await expect(service.createWithDuplicateCheck(createUserDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('应该在GitHub ID重复时抛出ConflictException', async () => {
|
||||
mockRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 用户名检查通过
|
||||
.mockResolvedValueOnce(null) // 邮箱检查通过
|
||||
.mockResolvedValueOnce(null) // 手机号检查通过
|
||||
.mockResolvedValueOnce(mockUser); // GitHub ID已存在
|
||||
|
||||
await expect(service.createWithDuplicateCheck(createUserDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softRemove()', () => {
|
||||
it('应该成功软删除用户', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockRepository.softRemove.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.softRemove(BigInt(1));
|
||||
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: BigInt(1) }
|
||||
});
|
||||
expect(mockRepository.softRemove).toHaveBeenCalledWith(mockUser);
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('应该在软删除不存在的用户时抛出NotFoundException', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.softRemove(BigInt(999))).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBatch()', () => {
|
||||
it('应该成功批量创建用户', async () => {
|
||||
const batchDto = [
|
||||
{ ...createUserDto, username: 'user1', nickname: '用户1' },
|
||||
{ ...createUserDto, username: 'user2', nickname: '用户2' }
|
||||
];
|
||||
|
||||
const batchUsers = [
|
||||
{ ...mockUser, username: 'user1', nickname: '用户1' },
|
||||
{ ...mockUser, username: 'user2', nickname: '用户2' }
|
||||
];
|
||||
|
||||
mockRepository.save.mockResolvedValueOnce(batchUsers[0]).mockResolvedValueOnce(batchUsers[1]);
|
||||
|
||||
const result = await service.createBatch(batchDto);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].username).toBe('user1');
|
||||
expect(result[1].username).toBe('user2');
|
||||
});
|
||||
|
||||
it('应该在批量创建中某个用户失败时抛出异常', async () => {
|
||||
const batchDto = [
|
||||
{ ...createUserDto, username: 'user1' },
|
||||
{ username: '', nickname: '' } // 无效数据
|
||||
];
|
||||
|
||||
await expect(service.createBatch(batchDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update()', () => {
|
||||
@@ -435,7 +715,29 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 集成测试组
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证DTO到Entity的完整数据流
|
||||
* - 测试组件间的协作和集成
|
||||
* - 确保端到端流程的正确性
|
||||
*
|
||||
* 测试内容:
|
||||
* - DTO验证 → 实体创建 → 数据库保存的完整流程
|
||||
* - 可选字段的默认值处理
|
||||
* - 数据转换和映射的正确性
|
||||
*/
|
||||
describe('Integration Tests', () => {
|
||||
/**
|
||||
* 测试从DTO到Entity的完整数据流
|
||||
*
|
||||
* 验证点:
|
||||
* - DTO验证成功
|
||||
* - 数据正确转换为Entity
|
||||
* - 服务方法正确处理数据
|
||||
* - 返回结果符合预期
|
||||
*/
|
||||
it('应该完成从DTO到Entity的完整流程', async () => {
|
||||
// 1. 验证DTO
|
||||
const dto = plainToClass(CreateUserDto, createUserDto);
|
||||
@@ -474,19 +776,76 @@ describe('Users Entity, DTO and Service Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 错误处理测试组
|
||||
*
|
||||
* 测试目标:
|
||||
* - 验证各种异常情况的处理
|
||||
* - 测试错误恢复和降级机制
|
||||
* - 确保系统的健壮性和稳定性
|
||||
*
|
||||
* 测试内容:
|
||||
* - 数据库连接错误处理
|
||||
* - 并发操作冲突处理
|
||||
* - 系统异常的统一处理
|
||||
* - 搜索异常的降级处理
|
||||
* - 各种操作失败的异常抛出
|
||||
*
|
||||
* 异常处理策略:
|
||||
* - 业务异常:直接抛出对应的HTTP异常
|
||||
* - 系统异常:转换为BadRequestException
|
||||
* - 搜索异常:返回空结果而不抛出异常
|
||||
*/
|
||||
describe('Error Handling Tests', () => {
|
||||
/**
|
||||
* 测试数据库连接错误的处理
|
||||
*
|
||||
* 验证点:
|
||||
* - 数据库操作失败时抛出正确的异常
|
||||
* - 异常类型为BadRequestException
|
||||
* - 错误信息被正确记录
|
||||
*/
|
||||
it('应该正确处理数据库连接错误', async () => {
|
||||
mockRepository.save.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
await expect(service.create(createUserDto)).rejects.toThrow('Database connection failed');
|
||||
await expect(service.create(createUserDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该正确处理并发创建冲突', async () => {
|
||||
// 模拟并发情况:检查时不存在,保存时出现唯一约束错误
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
mockRepository.save.mockRejectedValue(new Error('Duplicate entry'));
|
||||
|
||||
await expect(service.create(createUserDto)).rejects.toThrow('Duplicate entry');
|
||||
await expect(service.create(createUserDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该正确处理搜索异常', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockRejectedValue(new Error('Search failed')),
|
||||
};
|
||||
|
||||
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.search('test');
|
||||
|
||||
// 搜索失败时应该返回空数组,不抛出异常
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该正确处理更新时的系统异常', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockRepository.save.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
await expect(service.update(BigInt(1), { nickname: '新昵称' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('应该正确处理删除时的系统异常', async () => {
|
||||
mockRepository.findOne.mockResolvedValue(mockUser);
|
||||
mockRepository.delete.mockRejectedValue(new Error('Delete failed'));
|
||||
|
||||
await expect(service.remove(BigInt(1))).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,12 @@
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-17
|
||||
*
|
||||
* @lastModified 2025-01-07 by moyin
|
||||
* @lastChange 添加完整的日志记录系统和详细的业务逻辑注释,优化异常处理和性能监控
|
||||
*/
|
||||
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
@@ -22,6 +25,8 @@ import { plainToClass } from 'class-transformer';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@@ -35,32 +40,81 @@ export class UsersService {
|
||||
* @throws BadRequestException 当数据验证失败时
|
||||
*/
|
||||
async create(createUserDto: CreateUserDto): Promise<Users> {
|
||||
// 验证DTO
|
||||
const dto = plainToClass(CreateUserDto, createUserDto);
|
||||
const validationErrors = await validate(dto);
|
||||
const startTime = Date.now();
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const errorMessages = validationErrors.map(error =>
|
||||
Object.values(error.constraints || {}).join(', ')
|
||||
).join('; ');
|
||||
throw new BadRequestException(`数据验证失败: ${errorMessages}`);
|
||||
this.logger.log('开始创建用户', {
|
||||
operation: 'create',
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证DTO
|
||||
const dto = plainToClass(CreateUserDto, createUserDto);
|
||||
const validationErrors = await validate(dto);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const errorMessages = validationErrors.map(error =>
|
||||
Object.values(error.constraints || {}).join(', ')
|
||||
).join('; ');
|
||||
|
||||
this.logger.warn('用户创建失败:数据验证失败', {
|
||||
operation: 'create',
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
validationErrors: errorMessages
|
||||
});
|
||||
|
||||
throw new BadRequestException(`数据验证失败: ${errorMessages}`);
|
||||
}
|
||||
|
||||
// 创建用户实体
|
||||
const user = new Users();
|
||||
user.username = createUserDto.username;
|
||||
user.email = createUserDto.email || null;
|
||||
user.phone = createUserDto.phone || null;
|
||||
user.password_hash = createUserDto.password_hash || null;
|
||||
user.nickname = createUserDto.nickname;
|
||||
user.github_id = createUserDto.github_id || null;
|
||||
user.avatar_url = createUserDto.avatar_url || null;
|
||||
user.role = createUserDto.role || 1;
|
||||
user.email_verified = createUserDto.email_verified || false;
|
||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||
|
||||
// 保存到数据库
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户创建成功', {
|
||||
operation: 'create',
|
||||
userId: savedUser.id.toString(),
|
||||
username: savedUser.username,
|
||||
email: savedUser.email,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return savedUser;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error('用户创建系统异常', {
|
||||
operation: 'create',
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw new BadRequestException('用户创建失败,请稍后重试');
|
||||
}
|
||||
|
||||
// 创建用户实体
|
||||
const user = new Users();
|
||||
user.username = createUserDto.username;
|
||||
user.email = createUserDto.email || null;
|
||||
user.phone = createUserDto.phone || null;
|
||||
user.password_hash = createUserDto.password_hash || null;
|
||||
user.nickname = createUserDto.nickname;
|
||||
user.github_id = createUserDto.github_id || null;
|
||||
user.avatar_url = createUserDto.avatar_url || null;
|
||||
user.role = createUserDto.role || 1;
|
||||
user.email_verified = createUserDto.email_verified || false;
|
||||
user.status = createUserDto.status || UserStatus.ACTIVE;
|
||||
|
||||
// 保存到数据库
|
||||
return await this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,48 +126,110 @@ export class UsersService {
|
||||
* @throws BadRequestException 当数据验证失败时
|
||||
*/
|
||||
async createWithDuplicateCheck(createUserDto: CreateUserDto): Promise<Users> {
|
||||
// 检查用户名是否已存在
|
||||
if (createUserDto.username) {
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
where: { username: createUserDto.username }
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
}
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始创建用户(带重复检查)', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
phone: createUserDto.phone,
|
||||
github_id: createUserDto.github_id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if (createUserDto.email) {
|
||||
const existingEmail = await this.usersRepository.findOne({
|
||||
where: { email: createUserDto.email }
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('邮箱已存在');
|
||||
try {
|
||||
// 检查用户名是否已存在
|
||||
if (createUserDto.username) {
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
where: { username: createUserDto.username }
|
||||
});
|
||||
if (existingUser) {
|
||||
this.logger.warn('用户创建失败:用户名已存在', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
username: createUserDto.username,
|
||||
existingUserId: existingUser.id.toString()
|
||||
});
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在
|
||||
if (createUserDto.phone) {
|
||||
const existingPhone = await this.usersRepository.findOne({
|
||||
where: { phone: createUserDto.phone }
|
||||
});
|
||||
if (existingPhone) {
|
||||
throw new ConflictException('手机号已存在');
|
||||
// 检查邮箱是否已存在
|
||||
if (createUserDto.email) {
|
||||
const existingEmail = await this.usersRepository.findOne({
|
||||
where: { email: createUserDto.email }
|
||||
});
|
||||
if (existingEmail) {
|
||||
this.logger.warn('用户创建失败:邮箱已存在', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
email: createUserDto.email,
|
||||
existingUserId: existingEmail.id.toString()
|
||||
});
|
||||
throw new ConflictException('邮箱已存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查GitHub ID是否已存在
|
||||
if (createUserDto.github_id) {
|
||||
const existingGithub = await this.usersRepository.findOne({
|
||||
where: { github_id: createUserDto.github_id }
|
||||
});
|
||||
if (existingGithub) {
|
||||
throw new ConflictException('GitHub ID已存在');
|
||||
// 检查手机号是否已存在
|
||||
if (createUserDto.phone) {
|
||||
const existingPhone = await this.usersRepository.findOne({
|
||||
where: { phone: createUserDto.phone }
|
||||
});
|
||||
if (existingPhone) {
|
||||
this.logger.warn('用户创建失败:手机号已存在', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
phone: createUserDto.phone,
|
||||
existingUserId: existingPhone.id.toString()
|
||||
});
|
||||
throw new ConflictException('手机号已存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调用普通的创建方法
|
||||
return await this.create(createUserDto);
|
||||
// 检查GitHub ID是否已存在
|
||||
if (createUserDto.github_id) {
|
||||
const existingGithub = await this.usersRepository.findOne({
|
||||
where: { github_id: createUserDto.github_id }
|
||||
});
|
||||
if (existingGithub) {
|
||||
this.logger.warn('用户创建失败:GitHub ID已存在', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
github_id: createUserDto.github_id,
|
||||
existingUserId: existingGithub.id.toString()
|
||||
});
|
||||
throw new ConflictException('GitHub ID已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 调用普通的创建方法
|
||||
const user = await this.create(createUserDto);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户创建成功(带重复检查)', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
userId: user.id.toString(),
|
||||
username: user.username,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof ConflictException || error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error('用户创建系统异常(带重复检查)', {
|
||||
operation: 'createWithDuplicateCheck',
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw new BadRequestException('用户创建失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,77 +305,223 @@ export class UsersService {
|
||||
/**
|
||||
* 更新用户信息
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @param updateData 更新的数据
|
||||
* 功能描述:
|
||||
* 更新指定用户的信息,包含完整的数据验证和唯一性检查
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否存在
|
||||
* 2. 检查更新字段的唯一性约束(用户名、邮箱、手机号、GitHub ID)
|
||||
* 3. 合并更新数据到现有用户实体
|
||||
* 4. 保存更新后的用户信息
|
||||
* 5. 记录操作日志
|
||||
*
|
||||
* @param id 用户ID,必须是有效的已存在用户
|
||||
* @param updateData 更新的数据,支持部分字段更新
|
||||
* @returns 更新后的用户实体
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
* @throws ConflictException 当更新的数据与其他用户冲突时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const updatedUser = await usersService.update(BigInt(1), {
|
||||
* nickname: '新昵称',
|
||||
* email: 'new@example.com'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async update(id: bigint, updateData: Partial<CreateUserDto>): Promise<Users> {
|
||||
// 检查用户是否存在
|
||||
const existingUser = await this.findOne(id);
|
||||
|
||||
// 检查更新数据的唯一性约束
|
||||
if (updateData.username && updateData.username !== existingUser.username) {
|
||||
const usernameExists = await this.usersRepository.findOne({
|
||||
where: { username: updateData.username }
|
||||
});
|
||||
if (usernameExists) {
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.email && updateData.email !== existingUser.email) {
|
||||
const emailExists = await this.usersRepository.findOne({
|
||||
where: { email: updateData.email }
|
||||
});
|
||||
if (emailExists) {
|
||||
throw new ConflictException('邮箱已存在');
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.phone && updateData.phone !== existingUser.phone) {
|
||||
const phoneExists = await this.usersRepository.findOne({
|
||||
where: { phone: updateData.phone }
|
||||
});
|
||||
if (phoneExists) {
|
||||
throw new ConflictException('手机号已存在');
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.github_id && updateData.github_id !== existingUser.github_id) {
|
||||
const githubExists = await this.usersRepository.findOne({
|
||||
where: { github_id: updateData.github_id }
|
||||
});
|
||||
if (githubExists) {
|
||||
throw new ConflictException('GitHub ID已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户数据
|
||||
Object.assign(existingUser, updateData);
|
||||
const startTime = Date.now();
|
||||
|
||||
return await this.usersRepository.save(existingUser);
|
||||
this.logger.log('开始更新用户信息', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
updateFields: Object.keys(updateData),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 检查用户是否存在 - 确保要更新的用户确实存在
|
||||
const existingUser = await this.findOne(id);
|
||||
|
||||
// 2. 检查更新数据的唯一性约束 - 防止违反数据库唯一约束
|
||||
|
||||
// 2.1 检查用户名唯一性 - 只有当用户名确实发生变化时才检查
|
||||
if (updateData.username && updateData.username !== existingUser.username) {
|
||||
const usernameExists = await this.usersRepository.findOne({
|
||||
where: { username: updateData.username }
|
||||
});
|
||||
if (usernameExists) {
|
||||
this.logger.warn('用户更新失败:用户名已存在', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
conflictUsername: updateData.username,
|
||||
existingUserId: usernameExists.id.toString()
|
||||
});
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 检查邮箱唯一性 - 只有当邮箱确实发生变化时才检查
|
||||
if (updateData.email && updateData.email !== existingUser.email) {
|
||||
const emailExists = await this.usersRepository.findOne({
|
||||
where: { email: updateData.email }
|
||||
});
|
||||
if (emailExists) {
|
||||
this.logger.warn('用户更新失败:邮箱已存在', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
conflictEmail: updateData.email,
|
||||
existingUserId: emailExists.id.toString()
|
||||
});
|
||||
throw new ConflictException('邮箱已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 检查手机号唯一性 - 只有当手机号确实发生变化时才检查
|
||||
if (updateData.phone && updateData.phone !== existingUser.phone) {
|
||||
const phoneExists = await this.usersRepository.findOne({
|
||||
where: { phone: updateData.phone }
|
||||
});
|
||||
if (phoneExists) {
|
||||
this.logger.warn('用户更新失败:手机号已存在', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
conflictPhone: updateData.phone,
|
||||
existingUserId: phoneExists.id.toString()
|
||||
});
|
||||
throw new ConflictException('手机号已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 检查GitHub ID唯一性 - 只有当GitHub ID确实发生变化时才检查
|
||||
if (updateData.github_id && updateData.github_id !== existingUser.github_id) {
|
||||
const githubExists = await this.usersRepository.findOne({
|
||||
where: { github_id: updateData.github_id }
|
||||
});
|
||||
if (githubExists) {
|
||||
this.logger.warn('用户更新失败:GitHub ID已存在', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
conflictGithubId: updateData.github_id,
|
||||
existingUserId: githubExists.id.toString()
|
||||
});
|
||||
throw new ConflictException('GitHub ID已存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 合并更新数据 - 使用Object.assign将新数据合并到现有实体
|
||||
Object.assign(existingUser, updateData);
|
||||
|
||||
// 4. 保存更新后的用户信息 - TypeORM会自动更新updated_at字段
|
||||
const updatedUser = await this.usersRepository.save(existingUser);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户信息更新成功', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
updateFields: Object.keys(updateData),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof NotFoundException || error instanceof ConflictException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error('用户更新系统异常', {
|
||||
operation: 'update',
|
||||
userId: id.toString(),
|
||||
updateData,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw new BadRequestException('用户更新失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @returns 删除操作结果
|
||||
* 功能描述:
|
||||
* 物理删除指定的用户记录,数据将从数据库中永久移除
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否存在
|
||||
* 2. 执行物理删除操作
|
||||
* 3. 返回删除结果统计
|
||||
* 4. 记录删除操作日志
|
||||
*
|
||||
* 注意事项:
|
||||
* - 这是物理删除,数据无法恢复
|
||||
* - 如需保留数据,请使用 softRemove 方法
|
||||
* - 删除前请确认用户没有关联的重要数据
|
||||
*
|
||||
* @param id 用户ID,必须是有效的已存在用户
|
||||
* @returns 删除操作结果,包含影响行数和操作消息
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await usersService.remove(BigInt(1));
|
||||
* console.log(`删除了 ${result.affected} 个用户`);
|
||||
* ```
|
||||
*/
|
||||
async remove(id: bigint): Promise<{ affected: number; message: string }> {
|
||||
// 检查用户是否存在
|
||||
await this.findOne(id);
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始删除用户', {
|
||||
operation: 'remove',
|
||||
userId: id.toString(),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 执行删除 - 使用where条件来处理bigint类型
|
||||
const result = await this.usersRepository.delete({ id });
|
||||
try {
|
||||
// 1. 检查用户是否存在 - 确保要删除的用户确实存在
|
||||
await this.findOne(id);
|
||||
|
||||
return {
|
||||
affected: result.affected || 0,
|
||||
message: `成功删除ID为 ${id} 的用户`
|
||||
};
|
||||
// 2. 执行删除操作 - 使用where条件来处理bigint类型
|
||||
const result = await this.usersRepository.delete({ id });
|
||||
|
||||
const deleteResult = {
|
||||
affected: result.affected || 0,
|
||||
message: `成功删除ID为 ${id} 的用户`
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户删除成功', {
|
||||
operation: 'remove',
|
||||
userId: id.toString(),
|
||||
affected: deleteResult.affected,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return deleteResult;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error('用户删除系统异常', {
|
||||
operation: 'remove',
|
||||
userId: id.toString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw new BadRequestException('用户删除失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,18 +590,83 @@ export class UsersService {
|
||||
/**
|
||||
* 搜索用户(根据用户名或昵称)
|
||||
*
|
||||
* @param keyword 搜索关键词
|
||||
* @param limit 限制数量
|
||||
* @returns 用户列表
|
||||
* 功能描述:
|
||||
* 根据关键词在用户名和昵称字段中进行模糊搜索,支持部分匹配
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 使用QueryBuilder构建复杂查询
|
||||
* 2. 对用户名和昵称字段进行LIKE模糊匹配
|
||||
* 3. 按创建时间倒序排列结果
|
||||
* 4. 限制返回数量防止性能问题
|
||||
*
|
||||
* 性能考虑:
|
||||
* - 使用数据库索引优化查询性能
|
||||
* - 限制返回数量避免大数据量问题
|
||||
* - 建议在用户名和昵称字段上建立索引
|
||||
*
|
||||
* @param keyword 搜索关键词,支持中文、英文、数字等字符
|
||||
* @param limit 限制数量,默认20条,建议不超过100
|
||||
* @returns 匹配的用户列表,按创建时间倒序排列
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 搜索包含"张三"的用户
|
||||
* const users = await usersService.search('张三', 10);
|
||||
*
|
||||
* // 搜索包含"admin"的用户
|
||||
* const adminUsers = await usersService.search('admin');
|
||||
* ```
|
||||
*/
|
||||
async search(keyword: string, limit: number = 20): Promise<Users[]> {
|
||||
return await this.usersRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.username LIKE :keyword OR user.nickname LIKE :keyword', {
|
||||
keyword: `%${keyword}%`
|
||||
})
|
||||
.orderBy('user.created_at', 'DESC')
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始搜索用户', {
|
||||
operation: 'search',
|
||||
keyword,
|
||||
limit,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 构建查询 - 使用QueryBuilder支持复杂的WHERE条件
|
||||
const queryBuilder = this.usersRepository.createQueryBuilder('user');
|
||||
|
||||
// 2. 添加搜索条件 - 在用户名和昵称中进行模糊匹配
|
||||
// 使用参数化查询防止SQL注入攻击
|
||||
const result = await queryBuilder
|
||||
.where('user.username LIKE :keyword OR user.nickname LIKE :keyword', {
|
||||
keyword: `%${keyword}%` // 前后加%实现模糊匹配
|
||||
})
|
||||
.orderBy('user.created_at', 'DESC') // 按创建时间倒序
|
||||
.limit(limit) // 限制返回数量
|
||||
.getMany();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户搜索完成', {
|
||||
operation: 'search',
|
||||
keyword,
|
||||
limit,
|
||||
resultCount: result.length,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('用户搜索异常', {
|
||||
operation: 'search',
|
||||
keyword,
|
||||
limit,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
// 搜索失败时返回空数组,不影响用户体验
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,12 @@
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-17
|
||||
*
|
||||
* @lastModified 2025-01-07 by Kiro
|
||||
* @lastChange 添加日志记录系统,统一异常处理和性能监控
|
||||
*/
|
||||
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { Users } from './users.entity';
|
||||
import { CreateUserDto } from './users.dto';
|
||||
import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum';
|
||||
@@ -30,6 +33,7 @@ import { plainToClass } from 'class-transformer';
|
||||
|
||||
@Injectable()
|
||||
export class UsersMemoryService {
|
||||
private readonly logger = new Logger(UsersMemoryService.name);
|
||||
private users: Map<bigint, Users> = new Map();
|
||||
private currentId: bigint = BigInt(1);
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateCol
|
||||
import { Users } from '../users/users.entity';
|
||||
|
||||
@Entity('zulip_accounts')
|
||||
@Index(['zulip_user_id'], { unique: true })
|
||||
@Index(['zulip_email'], { unique: true })
|
||||
@Index(['zulipUserId'], { unique: true })
|
||||
@Index(['zulipEmail'], { unique: true })
|
||||
export class ZulipAccounts {
|
||||
/**
|
||||
* 主键ID
|
||||
|
||||
58
src/main.ts
58
src/main.ts
@@ -65,10 +65,58 @@ async function bootstrap() {
|
||||
// 配置Swagger文档
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Pixel Game Server API')
|
||||
.setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册、验证码登录、邮箱冲突检测等功能')
|
||||
.setVersion('1.1.1')
|
||||
.addTag('auth', '用户认证相关接口')
|
||||
.addTag('admin', '管理员后台相关接口')
|
||||
.setDescription(`
|
||||
像素游戏服务器API文档 - 包含用户认证、聊天系统、Zulip集成等功能
|
||||
|
||||
## 主要功能模块
|
||||
|
||||
### 🔐 用户认证 (auth)
|
||||
- 用户注册、登录
|
||||
- JWT Token 管理
|
||||
- 邮箱验证和密码重置
|
||||
- 验证码登录
|
||||
|
||||
### 💬 聊天系统 (chat)
|
||||
- WebSocket 实时聊天
|
||||
- 聊天历史记录
|
||||
- 系统状态监控
|
||||
- Zulip 集成状态
|
||||
|
||||
### 👑 管理员后台 (admin)
|
||||
- 用户管理
|
||||
- 系统监控
|
||||
- 日志查看
|
||||
|
||||
## WebSocket 连接
|
||||
|
||||
游戏聊天功能主要通过 WebSocket 实现:
|
||||
|
||||
**连接地址**: \`ws://localhost:3000/game\`
|
||||
|
||||
**支持的事件**:
|
||||
- \`login\`: 用户登录(需要 JWT Token)
|
||||
- \`chat\`: 发送聊天消息
|
||||
- \`position_update\`: 位置更新
|
||||
|
||||
**JWT Token 要求**:
|
||||
- issuer: \`whale-town\`
|
||||
- audience: \`whale-town-users\`
|
||||
- type: \`access\`
|
||||
- 必需字段: \`sub\`, \`username\`, \`email\`, \`role\`
|
||||
|
||||
## Zulip 集成
|
||||
|
||||
系统集成了 Zulip 聊天服务,实现游戏内聊天与 Zulip 社群的双向同步。
|
||||
|
||||
**支持的地图**:
|
||||
- Whale Port (鲸鱼港)
|
||||
- Pumpkin Valley (南瓜谷)
|
||||
- Novice Village (新手村)
|
||||
`)
|
||||
.setVersion('2.0.0')
|
||||
.addTag('auth', '🔐 用户认证相关接口')
|
||||
.addTag('chat', '💬 聊天系统相关接口')
|
||||
.addTag('admin', '👑 管理员后台相关接口')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
@@ -80,6 +128,8 @@ async function bootstrap() {
|
||||
},
|
||||
'JWT-auth',
|
||||
)
|
||||
.addServer('http://localhost:3000', '开发环境')
|
||||
.addServer('https://whaletownend.xinghangee.icu', '生产环境')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
||||
131
test_zulip.js
131
test_zulip.js
@@ -1,131 +0,0 @@
|
||||
const io = require('socket.io-client');
|
||||
|
||||
// 使用用户 API Key 测试 Zulip 集成
|
||||
async function testWithUserApiKey() {
|
||||
console.log('🚀 使用用户 API Key 测试 Zulip 集成...');
|
||||
console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8');
|
||||
console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/');
|
||||
console.log('📡 游戏服务器: https://whaletownend.xinghangee.icu/game');
|
||||
|
||||
const socket = io('wss://whaletownend.xinghangee.icu/game', {
|
||||
transports: ['websocket', 'polling'], // WebSocket优先,polling备用
|
||||
timeout: 20000,
|
||||
forceNew: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
let testStep = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket 连接成功');
|
||||
testStep = 1;
|
||||
|
||||
// 使用包含用户 API Key 的 token
|
||||
const loginMessage = {
|
||||
type: 'login',
|
||||
token: 'lCPWCPfGh7...fGF8_user_token'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)');
|
||||
socket.emit('login', loginMessage);
|
||||
});
|
||||
|
||||
socket.on('login_success', (data) => {
|
||||
console.log('✅ 步骤 1 完成: 登录成功');
|
||||
console.log(' 会话ID:', data.sessionId);
|
||||
console.log(' 用户ID:', data.userId);
|
||||
console.log(' 用户名:', data.username);
|
||||
console.log(' 当前地图:', data.currentMap);
|
||||
testStep = 2;
|
||||
|
||||
// 等待 Zulip 客户端初始化
|
||||
console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...');
|
||||
setTimeout(() => {
|
||||
const chatMessage = {
|
||||
t: 'chat',
|
||||
content: '🎮 【用户API Key测试】来自游戏的消息!\\n' +
|
||||
'时间: ' + new Date().toLocaleString() + '\\n' +
|
||||
'使用用户 API Key 发送此消息。',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)');
|
||||
console.log(' 目标 Stream: Whale Port');
|
||||
socket.emit('chat', chatMessage);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
socket.on('chat_sent', (data) => {
|
||||
console.log('✅ 步骤 2 完成: 消息发送成功');
|
||||
console.log(' 响应:', JSON.stringify(data, null, 2));
|
||||
|
||||
// 只在第一次收到 chat_sent 时发送第二条消息
|
||||
if (testStep === 2) {
|
||||
testStep = 3;
|
||||
|
||||
setTimeout(() => {
|
||||
// 先切换到 Pumpkin Valley 地图
|
||||
console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图');
|
||||
const positionUpdate = {
|
||||
t: 'position',
|
||||
x: 150,
|
||||
y: 400,
|
||||
mapId: 'pumpkin_valley'
|
||||
};
|
||||
socket.emit('position_update', positionUpdate);
|
||||
|
||||
// 等待位置更新后发送消息
|
||||
setTimeout(() => {
|
||||
const chatMessage2 = {
|
||||
t: 'chat',
|
||||
content: '🎃 在南瓜谷发送的测试消息!',
|
||||
scope: 'local'
|
||||
};
|
||||
|
||||
console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息');
|
||||
socket.emit('chat', chatMessage2);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('chat_render', (data) => {
|
||||
console.log('📨 收到来自 Zulip 的消息:');
|
||||
console.log(' 发送者:', data.from);
|
||||
console.log(' 内容:', data.txt);
|
||||
console.log(' Stream:', data.stream || '未知');
|
||||
console.log(' Topic:', data.topic || '未知');
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.log('❌ 收到错误:', JSON.stringify(error, null, 2));
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 WebSocket 连接已关闭');
|
||||
console.log('');
|
||||
console.log('📊 测试结果:');
|
||||
console.log(' 完成步骤:', testStep, '/ 4');
|
||||
if (testStep >= 3) {
|
||||
console.log(' ✅ 核心功能正常!');
|
||||
console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息');
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ 连接错误:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 20秒后自动关闭(给足够时间完成测试)
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 测试时间到,关闭连接');
|
||||
socket.disconnect();
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
console.log('🔧 准备测试环境...');
|
||||
testWithUserApiKey().catch(console.error);
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* Zulip用户注册真实环境测试脚本
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试Zulip用户注册功能在真实环境下的表现
|
||||
* - 验证API调用是否正常工作
|
||||
* - 检查配置是否正确
|
||||
*
|
||||
* 使用方法:
|
||||
* node test_zulip_registration.js
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const { URLSearchParams } = require('url');
|
||||
|
||||
// 配置信息
|
||||
const config = {
|
||||
zulipServerUrl: 'https://zulip.xinghangee.icu',
|
||||
zulipBotEmail: 'angjustinl@mail.angforever.top',
|
||||
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否存在
|
||||
*/
|
||||
async function checkUserExists(email) {
|
||||
console.log(`🔍 检查用户是否存在: ${email}`);
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`📊 获取到 ${data.members?.length || 0} 个用户`);
|
||||
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
const userExists = data.members.some(user =>
|
||||
user.email && user.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
|
||||
return userExists;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 检查用户存在性失败:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试用户
|
||||
*/
|
||||
async function createTestUser(email, fullName, password) {
|
||||
console.log(`🚀 开始创建用户: ${email}`);
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.append('email', email);
|
||||
requestBody.append('full_name', fullName);
|
||||
|
||||
if (password) {
|
||||
requestBody.append('password', password);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: requestBody.toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ 用户创建失败: ${response.status} ${response.statusText}`);
|
||||
console.log(`📝 错误信息: ${data.msg || data.message || '未知错误'}`);
|
||||
return { success: false, error: data.msg || data.message };
|
||||
}
|
||||
|
||||
console.log(`✅ 用户创建成功! 用户ID: ${data.user_id}`);
|
||||
return { success: true, userId: data.user_id };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 创建用户异常:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async function testConnection() {
|
||||
console.log('🔗 测试Zulip服务器连接...');
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ 连接成功! 服务器版本: ${data.zulip_version || '未知'}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接异常:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试函数
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🎯 开始Zulip用户注册测试');
|
||||
console.log('=' * 50);
|
||||
|
||||
// 1. 测试连接
|
||||
const connected = await testConnection();
|
||||
if (!connected) {
|
||||
console.log('❌ 无法连接到Zulip服务器,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 2. 生成测试用户信息
|
||||
const timestamp = Date.now();
|
||||
const testEmail = `test_user_${timestamp}@example.com`;
|
||||
const testFullName = `Test User ${timestamp}`;
|
||||
const testPassword = 'test123456';
|
||||
|
||||
console.log(`📋 测试用户信息:`);
|
||||
console.log(` 邮箱: ${testEmail}`);
|
||||
console.log(` 姓名: ${testFullName}`);
|
||||
console.log(` 密码: ${testPassword}`);
|
||||
console.log('');
|
||||
|
||||
// 3. 检查用户是否已存在
|
||||
const userExists = await checkUserExists(testEmail);
|
||||
if (userExists) {
|
||||
console.log('⚠️ 用户已存在,跳过创建测试');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 4. 创建用户
|
||||
const createResult = await createTestUser(testEmail, testFullName, testPassword);
|
||||
|
||||
console.log('');
|
||||
console.log('📊 测试结果:');
|
||||
if (createResult.success) {
|
||||
console.log('✅ 用户注册功能正常工作');
|
||||
console.log(` 新用户ID: ${createResult.userId}`);
|
||||
} else {
|
||||
console.log('❌ 用户注册功能存在问题');
|
||||
console.log(` 错误信息: ${createResult.error}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('🎉 测试完成');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
main().catch(error => {
|
||||
console.error('💥 测试过程中发生未处理的错误:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Zulip用户管理真实环境测试脚本
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试Zulip用户管理功能在真实环境下的表现
|
||||
* - 验证用户查询、验证等API调用是否正常工作
|
||||
* - 检查配置是否正确
|
||||
*
|
||||
* 使用方法:
|
||||
* node test_zulip_user_management.js
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-06
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// 配置信息
|
||||
const config = {
|
||||
zulipServerUrl: 'https://zulip.xinghangee.icu',
|
||||
zulipBotEmail: 'angjustinl@mail.angforever.top',
|
||||
zulipBotApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有用户列表
|
||||
*/
|
||||
async function getAllUsers() {
|
||||
console.log('📋 获取所有用户列表...');
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users`;
|
||||
const auth = Buffer.from(`${config.zulipBotEmail}:${config.zulipBotApiKey}`).toString('base64');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ 获取用户列表失败: ${response.status} ${response.statusText}`);
|
||||
return { success: false, error: `${response.status} ${response.statusText}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const users = data.members?.map(user => ({
|
||||
userId: user.user_id,
|
||||
email: user.email,
|
||||
fullName: user.full_name,
|
||||
isActive: user.is_active,
|
||||
isAdmin: user.is_admin,
|
||||
isBot: user.is_bot,
|
||||
})) || [];
|
||||
|
||||
console.log(`✅ 成功获取 ${users.length} 个用户`);
|
||||
|
||||
// 显示前几个用户信息
|
||||
console.log('👥 用户列表预览:');
|
||||
users.slice(0, 5).forEach((user, index) => {
|
||||
console.log(` ${index + 1}. ${user.fullName} (${user.email})`);
|
||||
console.log(` ID: ${user.userId}, 活跃: ${user.isActive}, 管理员: ${user.isAdmin}, 机器人: ${user.isBot}`);
|
||||
});
|
||||
|
||||
if (users.length > 5) {
|
||||
console.log(` ... 还有 ${users.length - 5} 个用户`);
|
||||
}
|
||||
|
||||
return { success: true, users, totalCount: users.length };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 获取用户列表异常:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定用户是否存在
|
||||
*/
|
||||
async function checkUserExists(email) {
|
||||
console.log(`🔍 检查用户是否存在: ${email}`);
|
||||
|
||||
try {
|
||||
const usersResult = await getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userExists = usersResult.users.some(user =>
|
||||
user.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
console.log(`✅ 用户存在性检查完成: ${userExists ? '存在' : '不存在'}`);
|
||||
return userExists;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 检查用户存在性失败:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详细信息
|
||||
*/
|
||||
async function getUserInfo(email) {
|
||||
console.log(`📝 获取用户信息: ${email}`);
|
||||
|
||||
try {
|
||||
const usersResult = await getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
console.log(`❌ 无法获取用户列表: ${usersResult.error}`);
|
||||
return { success: false, error: usersResult.error };
|
||||
}
|
||||
|
||||
const user = usersResult.users.find(u =>
|
||||
u.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
console.log(`❌ 用户不存在: ${email}`);
|
||||
return { success: false, error: '用户不存在' };
|
||||
}
|
||||
|
||||
console.log(`✅ 用户信息获取成功:`);
|
||||
console.log(` 用户ID: ${user.userId}`);
|
||||
console.log(` 邮箱: ${user.email}`);
|
||||
console.log(` 姓名: ${user.fullName}`);
|
||||
console.log(` 状态: ${user.isActive ? '活跃' : '非活跃'}`);
|
||||
console.log(` 权限: ${user.isAdmin ? '管理员' : '普通用户'}`);
|
||||
console.log(` 类型: ${user.isBot ? '机器人' : '真实用户'}`);
|
||||
|
||||
return { success: true, user };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 获取用户信息失败:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户API Key
|
||||
*/
|
||||
async function testUserApiKey(email, apiKey) {
|
||||
console.log(`🔑 测试用户API Key: ${email}`);
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/users/me`;
|
||||
const auth = Buffer.from(`${email}:${apiKey}`).toString('base64');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const isValid = response.ok;
|
||||
|
||||
if (isValid) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ API Key有效! 用户信息:`);
|
||||
console.log(` 用户ID: ${data.user_id}`);
|
||||
console.log(` 邮箱: ${data.email}`);
|
||||
console.log(` 姓名: ${data.full_name}`);
|
||||
} else {
|
||||
console.log(`❌ API Key无效: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 测试API Key异常:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async function testConnection() {
|
||||
console.log('🔗 测试Zulip服务器连接...');
|
||||
|
||||
try {
|
||||
const url = `${config.zulipServerUrl}/api/v1/server_settings`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ 连接成功! 服务器信息:`);
|
||||
console.log(` 版本: ${data.zulip_version || '未知'}`);
|
||||
console.log(` 服务器: ${data.realm_name || '未知'}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ 连接失败: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接异常:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主测试函数
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🎯 开始Zulip用户管理测试');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// 1. 测试连接
|
||||
const connected = await testConnection();
|
||||
if (!connected) {
|
||||
console.log('❌ 无法连接到Zulip服务器,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 2. 获取所有用户列表
|
||||
const usersResult = await getAllUsers();
|
||||
if (!usersResult.success) {
|
||||
console.log('❌ 无法获取用户列表,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 3. 测试用户存在性检查
|
||||
const testEmails = [
|
||||
'angjustinl@mail.angforever.top', // 应该存在
|
||||
'nonexistent@example.com', // 应该不存在
|
||||
];
|
||||
|
||||
console.log('🔍 测试用户存在性检查:');
|
||||
for (const email of testEmails) {
|
||||
const exists = await checkUserExists(email);
|
||||
console.log(` ${email}: ${exists ? '✅ 存在' : '❌ 不存在'}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 4. 测试获取用户信息
|
||||
console.log('📝 测试获取用户信息:');
|
||||
const existingEmail = 'angjustinl@mail.angforever.top';
|
||||
const userInfoResult = await getUserInfo(existingEmail);
|
||||
|
||||
console.log('');
|
||||
|
||||
// 5. 测试API Key验证(如果有的话)
|
||||
console.log('🔑 测试API Key验证:');
|
||||
const testApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; // 这是我们的测试API Key
|
||||
const apiKeyValid = await testUserApiKey(existingEmail, testApiKey);
|
||||
|
||||
console.log('');
|
||||
console.log('📊 测试结果总结:');
|
||||
console.log(`✅ 服务器连接: 正常`);
|
||||
console.log(`✅ 用户列表获取: 正常 (${usersResult.totalCount} 个用户)`);
|
||||
console.log(`✅ 用户存在性检查: 正常`);
|
||||
console.log(`✅ 用户信息获取: ${userInfoResult.success ? '正常' : '异常'}`);
|
||||
console.log(`✅ API Key验证: ${apiKeyValid ? '正常' : '异常'}`);
|
||||
|
||||
console.log('');
|
||||
console.log('🎉 用户管理功能测试完成');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
main().catch(error => {
|
||||
console.error('💥 测试过程中发生未处理的错误:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user