12 Commits

Author SHA1 Message Date
moyin
963e6ca90f refactor(auth): 移除登录注册时的Zulip内存关联逻辑
范围: src/business/auth/
涉及文件:
- src/business/auth/login.service.ts
- src/business/auth/register.service.ts

主要改进:
- 移除登录时建立Zulip内存关联的代码
- 移除注册时建立Zulip内存关联的代码
- 改为在WebSocket连接时由Zulip客户端创建内存关联
- 优化了内存关联的时机,避免不必要的提前创建

技术说明:
- 原逻辑在登录/注册时就建立内存关联,但用户可能不会立即使用Zulip
- 新逻辑延迟到WebSocket连接时创建,更加合理和高效
- 减少了登录/注册流程的复杂度和耦合度
2026-01-19 17:59:58 +08:00
moyin
cd2a197288 feat(chat): 添加地图切换功能
范围: src/gateway/chat/
- 新增 change_map 事件处理
- 实现 handleChangeMap() 方法
- 支持玩家在不同地图间切换
- 自动更新房间成员和广播通知
- 完善地图切换的错误处理

功能说明:
- 玩家可以通过 WebSocket 发送 change_map 事件切换地图
- 自动处理房间加入/离开逻辑
- 向旧地图广播玩家离开,向新地图广播玩家加入
- 支持携带初始位置坐标,默认使用 (400, 300)
2026-01-19 17:43:59 +08:00
01787d701c Merge pull request 'refactor:将 ZulipAccountsModule 改为全局单例模块' (#51) from docs/ai-reading-guide-20260115 into main
Reviewed-on: #51
2026-01-15 15:00:14 +08:00
6e7de1a11a Merge branch 'main' into docs/ai-reading-guide-20260115 2026-01-15 15:00:07 +08:00
moyin
d92a078fc7 refactor:将 ZulipAccountsModule 改为全局单例模块
- 在 AppModule 中统一导入 ZulipAccountsModule.forRoot()
- 移除 admin.module、auth.module、zulip.module 中的重复导入
- 添加数据库 charset: utf8mb4 配置,支持中文和 emoji
2026-01-15 14:58:28 +08:00
9785908ca9 Merge pull request 'docs/ai-reading-guide-20260115' (#50) from docs/ai-reading-guide-20260115 into main
Reviewed-on: #50
2026-01-15 14:31:33 +08:00
592a745b8f Merge branch 'main' into docs/ai-reading-guide-20260115 2026-01-15 14:31:26 +08:00
moyin
cde20c6fd7 docs:补充合并文档不纳入Git提交的规范说明
- 添加合并文档排除原因说明
- 补充操作规范和.gitignore配置建议
- 更新提交原则中的合并文档排除要求
2026-01-15 14:21:14 +08:00
moyin
a8de2564b6 docs:添加异常处理完整性检查规范
- 新增异常吞没问题定义和检查规则
- 添加Service/Repository层异常传播规范
- 补充常见错误模式和修复示例
- 更新检查清单和执行步骤顺序
2026-01-15 14:20:55 +08:00
299627dac7 Merge pull request 'docs:删除多余的文档' (#49) from feature/gateway-module-integration-20260115 into main
Reviewed-on: #49
2026-01-15 11:21:53 +08:00
ae3a256c52 Merge branch 'main' into feature/gateway-module-integration-20260115 2026-01-15 11:21:47 +08:00
97ea698f38 Merge pull request 'feature/gateway-module-integration-20260115' (#48) from feature/gateway-module-integration-20260115 into main
Reviewed-on: #48
2026-01-15 11:13:51 +08:00
9 changed files with 354 additions and 28 deletions

View File

@@ -177,6 +177,227 @@ private validateUserData(userData: CreateUserDto | UpdateUserDto): void {
}
```
## 🚨 异常处理完整性检查(关键规范)
### 问题定义
**异常吞没Exception Swallowing** 是指在 catch 块中捕获异常后,只记录日志但不重新抛出,导致:
- 调用方无法感知错误
- 方法返回 undefined 而非声明的类型
- 数据不一致或静默失败
- 难以调试和定位问题
### 检查规则
#### 规则1catch 块必须有明确的异常处理策略
```typescript
// ❌ 严重错误catch 块吞没异常
async create(createDto: CreateDto): Promise<ResponseDto> {
try {
const result = await this.repository.create(createDto);
return this.toResponseDto(result);
} catch (error) {
this.logger.error('创建失败', error);
// 错误:没有 throw方法返回 undefined
// 但声明返回 Promise<ResponseDto>
}
}
// ❌ 错误:只记录日志不处理
async findById(id: string): Promise<Entity> {
try {
return await this.repository.findById(id);
} catch (error) {
monitor.error(error);
// 错误:异常被吞没,调用方无法感知
}
}
// ✅ 正确:重新抛出异常
async create(createDto: CreateDto): Promise<ResponseDto> {
try {
const result = await this.repository.create(createDto);
return this.toResponseDto(result);
} catch (error) {
this.logger.error('创建失败', error);
throw error; // 必须重新抛出
}
}
// ✅ 正确:转换为特定异常类型
async create(createDto: CreateDto): Promise<ResponseDto> {
try {
const result = await this.repository.create(createDto);
return this.toResponseDto(result);
} catch (error) {
this.logger.error('创建失败', error);
if (error.message.includes('duplicate')) {
throw new ConflictException('记录已存在');
}
throw error; // 其他错误继续抛出
}
}
// ✅ 正确返回错误响应仅限顶层API
async create(createDto: CreateDto): Promise<ApiResponse<ResponseDto>> {
try {
const result = await this.repository.create(createDto);
return { success: true, data: this.toResponseDto(result) };
} catch (error) {
this.logger.error('创建失败', error);
return {
success: false,
error: error.message,
errorCode: 'CREATE_FAILED'
}; // 顶层API可以返回错误响应
}
}
```
#### 规则2Service 层方法必须传播异常
```typescript
// ❌ 错误Service 层吞没异常
@Injectable()
export class UserService {
async update(id: string, dto: UpdateDto): Promise<ResponseDto> {
try {
const result = await this.repository.update(id, dto);
return this.toResponseDto(result);
} catch (error) {
this.logger.error('更新失败', { id, error });
// 错误Service 层不应吞没异常
}
}
}
// ✅ 正确Service 层传播异常
@Injectable()
export class UserService {
async update(id: string, dto: UpdateDto): Promise<ResponseDto> {
try {
const result = await this.repository.update(id, dto);
return this.toResponseDto(result);
} catch (error) {
this.logger.error('更新失败', { id, error });
throw error; // 传播给调用方处理
}
}
}
```
#### 规则3Repository 层必须传播数据库异常
```typescript
// ❌ 错误Repository 层吞没数据库异常
@Injectable()
export class UserRepository {
async findById(id: bigint): Promise<User | null> {
try {
return await this.repository.findOne({ where: { id } });
} catch (error) {
this.logger.error('查询失败', { id, error });
// 错误:数据库异常被吞没,调用方以为查询成功但返回 null
}
}
}
// ✅ 正确Repository 层传播异常
@Injectable()
export class UserRepository {
async findById(id: bigint): Promise<User | null> {
try {
return await this.repository.findOne({ where: { id } });
} catch (error) {
this.logger.error('查询失败', { id, error });
throw error; // 数据库异常必须传播
}
}
}
```
### 异常处理层级规范
| 层级 | 异常处理策略 | 说明 |
|------|-------------|------|
| **Repository 层** | 必须 throw | 数据访问异常必须传播 |
| **Service 层** | 必须 throw | 业务异常必须传播给调用方 |
| **Business 层** | 必须 throw | 业务逻辑异常必须传播 |
| **Gateway/Controller 层** | 可以转换为 HTTP 响应 | 顶层可以将异常转换为错误响应 |
### 检查清单
- [ ] **所有 catch 块是否有 throw 语句?**
- [ ] **方法返回类型与实际返回是否一致?**(避免返回 undefined
- [ ] **Service/Repository 层是否传播异常?**
- [ ] **只有顶层 API 才能将异常转换为错误响应?**
- [ ] **异常日志是否包含足够的上下文信息?**
### 快速检查命令
```bash
# 搜索可能吞没异常的 catch 块(没有 throw 的 catch
# 在代码审查时重点关注这些位置
grep -rn "catch.*error" --include="*.ts" | grep -v "throw"
```
### 常见错误模式
#### 模式1性能监控后忘记抛出
```typescript
// ❌ 常见错误
} catch (error) {
monitor.error(error); // 只记录监控
// 忘记 throw error;
}
// ✅ 正确
} catch (error) {
monitor.error(error);
throw error; // 必须抛出
}
```
#### 模式2条件分支遗漏 throw
```typescript
// ❌ 常见错误
} catch (error) {
if (error.code === 'DUPLICATE') {
throw new ConflictException('已存在');
}
// else 分支忘记 throw
this.logger.error(error);
}
// ✅ 正确
} catch (error) {
if (error.code === 'DUPLICATE') {
throw new ConflictException('已存在');
}
this.logger.error(error);
throw error; // else 分支也要抛出
}
```
#### 模式3返回类型不匹配
```typescript
// ❌ 错误:声明返回 Promise<Entity> 但可能返回 undefined
async findById(id: string): Promise<Entity> {
try {
return await this.repo.findById(id);
} catch (error) {
this.logger.error(error);
// 没有 throwTypeScript 不会报错但运行时返回 undefined
}
}
// ✅ 正确
async findById(id: string): Promise<Entity> {
try {
return await this.repo.findById(id);
} catch (error) {
this.logger.error(error);
throw error;
}
}
```
## 🚫 TODO项处理强制要求
### 处理原则
@@ -323,12 +544,19 @@ describe('AdminService Properties', () => {
- 抽象为可复用的工具方法
- 消除代码重复
6. **处理所有TODO项**
6. **🚨 检查异常处理完整性(关键步骤)**
- 扫描所有 catch 块
- 检查是否有 throw 语句
- 验证 Service/Repository 层是否传播异常
- 确认方法返回类型与实际返回一致
- 识别异常吞没模式并修复
7. **处理所有TODO项**
- 搜索所有TODO注释
- 要求真正实现功能或删除代码
- 确保最终文件无TODO项
7. **游戏服务器特殊检查**
8. **游戏服务器特殊检查**
- WebSocket连接管理完整性
- 双模式服务行为一致性
- 属性测试实现质量

View File

@@ -505,6 +505,37 @@ mkdir -p docs/merge-requests
- **监控要点**:关注 [具体的监控指标]
```
### 🚨 合并文档不纳入Git提交
**重要合并文档仅用于本地记录和合并操作参考不应加入到Git提交中**
#### 原因说明
- 合并文档是临时性的操作记录,不属于项目代码的一部分
- 避免在代码仓库中产生大量临时文档
- 合并完成后相关信息已体现在Git提交历史和PR记录中
#### 操作规范
```bash
# ❌ 禁止将合并文档加入Git提交
git add docs/merge-requests/ # 禁止!
# ✅ 正确做法:确保合并文档不被提交
# 方法1在.gitignore中已配置忽略推荐
# 方法2提交时明确排除
git add . -- ':!docs/merge-requests/'
# ✅ 检查暂存区,确认没有合并文档
git diff --cached --name-only | grep "merge-requests"
# 如果有输出,需要取消暂存
git reset HEAD docs/merge-requests/
```
#### .gitignore 配置建议
确保项目的 `.gitignore` 文件中包含:
```
# 合并文档目录(不纳入版本控制)
docs/merge-requests/
```
### 📝 独立合并文档创建示例
#### 1. 创建合并文档目录(如果不存在)
@@ -689,6 +720,7 @@ git remote show [远程仓库名]
- **完整性**:每次提交的代码都应该能正常运行
- **描述性**:提交信息要清晰描述改动内容、范围和原因
- **一致性**:文件修改记录必须与实际修改内容一致
- **合并文档排除**`docs/merge-requests/` 目录下的合并文档不纳入Git提交
### 质量保证
- 提交前必须验证代码能正常运行

View File

@@ -6,6 +6,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LoggerModule } from './core/utils/logger/logger.module';
import { UsersModule } from './core/db/users/users.module';
import { ZulipAccountsModule } from './core/db/zulip_accounts/zulip_accounts.module';
import { LoginCoreModule } from './core/login_core/login_core.module';
import { AuthGatewayModule } from './gateway/auth/auth.gateway.module';
import { ChatGatewayModule } from './gateway/chat/chat.gateway.module';
@@ -62,6 +63,8 @@ function isDatabaseConfigured(): boolean {
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
// 字符集配置 - 支持中文和emoji
charset: 'utf8mb4',
// 添加连接超时和重试配置
connectTimeout: 10000,
retryAttempts: 3,
@@ -70,6 +73,8 @@ function isDatabaseConfigured(): boolean {
] : []),
// 根据数据库配置选择用户模块模式
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
// Zulip账号关联模块 - 全局单例,其他模块无需重复导入
ZulipAccountsModule.forRoot(),
LoginCoreModule,
AuthGatewayModule, // 认证网关模块
ChatGatewayModule, // 聊天网关模块

View File

@@ -27,7 +27,6 @@ import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
import { LoggerModule } from '../../core/utils/logger/logger.module';
import { UsersModule } from '../../core/db/users/users.module';
import { UserProfilesModule } from '../../core/db/user_profiles/user_profiles.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminDatabaseController } from './admin_database.controller';
@@ -55,8 +54,7 @@ function isDatabaseConfigured(): boolean {
UsersModule,
// 根据数据库配置选择UserProfiles模块模式
isDatabaseConfigured() ? UserProfilesModule.forDatabase() : UserProfilesModule.forMemory(),
// 根据数据库配置选择ZulipAccounts模块模式
isDatabaseConfigured() ? ZulipAccountsModule.forDatabase() : ZulipAccountsModule.forMemory(),
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
// 注册AdminOperationLog实体
TypeOrmModule.forFeature([AdminOperationLog])
],

View File

@@ -36,7 +36,6 @@ import { LoginService } from './login.service';
import { RegisterService } from './register.service';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
import { UsersModule } from '../../core/db/users/users.module';
@Module({
@@ -44,7 +43,7 @@ import { UsersModule } from '../../core/db/users/users.module';
// 导入核心层模块
LoginCoreModule,
ZulipCoreModule,
ZulipAccountsModule.forRoot(),
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
UsersModule,
],
providers: [

View File

@@ -714,13 +714,7 @@ export class LoginService {
apiKeyResult.apiKey!
);
// 4. 更新内存关联
await this.zulipAccountService.linkGameAccount(
user.id.toString(),
zulipAccount.zulipUserId,
zulipAccount.zulipEmail,
apiKeyResult.apiKey!
);
// 注意不在登录时建立内存关联Zulip客户端将在WebSocket连接时创建
const duration = Date.now() - startTime;

View File

@@ -533,15 +533,7 @@ export class RegisterService {
status: 'active',
});
// 6. 建立游戏账号与Zulip账号的内存关联用于当前会话
if (finalApiKey) {
await this.zulipAccountService.linkGameAccount(
gameUser.id.toString(),
createResult.userId, // 已在上面验证不为 undefined
createResult.email!,
finalApiKey
);
}
// 注意不在注册时建立内存关联Zulip客户端将在WebSocket连接时创建
const duration = Date.now() - startTime;

View File

@@ -36,7 +36,6 @@ import { ZulipEventProcessorService } from './services/zulip_event_processor.ser
import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service';
// 依赖模块
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
import { RedisModule } from '../../core/redis/redis.module';
import { LoggerModule } from '../../core/utils/logger/logger.module';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
@@ -50,8 +49,7 @@ import { ChatModule } from '../chat/chat.module';
CacheModule.register(),
// Zulip核心服务模块
ZulipCoreModule,
// Zulip账号关联模块
ZulipAccountsModule.forRoot(),
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
// Redis模块
RedisModule,
// 日志模块

View File

@@ -169,6 +169,9 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
case 'position':
await this.handlePosition(ws, message);
break;
case 'change_map':
await this.handleChangeMap(ws, message);
break;
default:
this.logger.warn(`未知消息类型: ${messageType}`);
this.sendError(ws, `未知消息类型: ${messageType}`);
@@ -254,7 +257,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
* 处理聊天消息
*
* @param ws WebSocket 连接实例
* @param message 聊天消息(包含 content, scope
* @param message 聊天消息(包含 content, scope, mapId
*/
private async handleChat(ws: ExtendedWebSocket, message: any) {
if (!ws.authenticated) {
@@ -271,7 +274,8 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
const result = await this.chatService.sendChatMessage({
socketId: ws.id,
content: message.content,
scope: message.scope || 'local'
scope: message.scope || 'local',
mapId: message.mapId || ws.currentMap, // 支持指定目标地图
});
if (result.success) {
@@ -335,6 +339,82 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
}
}
/**
* 处理切换地图
*
* @param ws WebSocket 连接实例
* @param message 切换地图消息(包含 mapId
*/
private async handleChangeMap(ws: ExtendedWebSocket, message: any) {
if (!ws.authenticated) {
this.sendError(ws, '请先登录');
return;
}
if (!message.mapId) {
this.sendError(ws, '地图ID不能为空');
return;
}
try {
const oldMapId = ws.currentMap;
const newMapId = message.mapId;
// 如果地图相同,直接返回成功
if (oldMapId === newMapId) {
this.sendMessage(ws, {
t: 'map_changed',
mapId: newMapId,
message: '已在当前地图'
});
return;
}
// 更新房间
this.leaveMapRoom(ws.id, oldMapId);
this.joinMapRoom(ws.id, newMapId);
ws.currentMap = newMapId;
// 更新会话中的地图信息(使用默认位置)
await this.chatService.updatePlayerPosition({
socketId: ws.id,
x: message.x || 400,
y: message.y || 300,
mapId: newMapId
});
// 通知客户端切换成功
this.sendMessage(ws, {
t: 'map_changed',
mapId: newMapId,
oldMapId: oldMapId,
message: '地图切换成功'
});
// 向旧地图广播玩家离开
this.broadcastToMap(oldMapId, {
t: 'player_left',
userId: ws.userId,
username: ws.username,
mapId: oldMapId
});
// 向新地图广播玩家加入
this.broadcastToMap(newMapId, {
t: 'player_joined',
userId: ws.userId,
username: ws.username,
mapId: newMapId
}, ws.id);
this.logger.log(`用户切换地图: ${ws.username} (${oldMapId} -> ${newMapId})`);
} catch (error) {
this.logger.error('切换地图处理失败', error);
this.sendError(ws, '切换地图处理失败');
}
}
/**
* 处理连接关闭
*