refactor:重构Zulip模块按业务功能模块化架构

- 将技术实现服务从business层迁移到core层
- 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务
- 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则
- 通过依赖注入实现业务层与核心层的解耦
- 更新模块导入关系,确保架构分层清晰

重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
This commit is contained in:
moyin
2025-12-31 15:44:36 +08:00
parent 5140bd1a54
commit 2d10131838
36 changed files with 2773 additions and 125 deletions

View File

@@ -0,0 +1,172 @@
# Zulip集成业务模块
## 架构重构说明
本模块已按照项目的分层架构要求进行重构,将技术实现细节移动到核心服务层,业务逻辑保留在业务层。
### 重构前后对比
#### 重构前(❌ 违反架构原则)
```
src/business/zulip/services/
├── zulip_client.service.ts # 技术实现API调用
├── zulip_client_pool.service.ts # 技术实现:连接池管理
├── config_manager.service.ts # 技术实现:配置管理
├── zulip_event_processor.service.ts # 技术实现:事件处理
├── session_manager.service.ts # ✅ 业务逻辑:会话管理
└── message_filter.service.ts # ✅ 业务逻辑:消息过滤
```
#### 重构后(✅ 符合架构原则)
```
# 业务逻辑层
src/business/zulip/
├── zulip.service.ts # 业务协调服务
├── zulip_websocket.gateway.ts # WebSocket业务网关
└── services/
├── session_manager.service.ts # 会话业务逻辑
└── message_filter.service.ts # 消息过滤业务规则
# 核心服务层
src/core/zulip/
├── interfaces/
│ └── zulip-core.interfaces.ts # 核心服务接口定义
├── services/
│ ├── zulip_client.service.ts # Zulip API封装
│ ├── zulip_client_pool.service.ts # 客户端池管理
│ ├── config_manager.service.ts # 配置管理
│ ├── zulip_event_processor.service.ts # 事件处理
│ └── ... # 其他技术服务
└── zulip-core.module.ts # 核心服务模块
```
### 架构优势
#### 1. 单一职责原则
- **业务层**:只关注游戏相关的业务逻辑和规则
- **核心层**只处理技术实现和第三方API调用
#### 2. 依赖注入和接口抽象
```typescript
// 业务层通过接口依赖核心服务
constructor(
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
) {}
```
#### 3. 易于测试和维护
- 业务逻辑可以独立测试,不依赖具体的技术实现
- 核心服务可以独立替换,不影响业务逻辑
- 接口定义清晰,便于理解和维护
### 服务职责划分
#### 业务逻辑层服务
| 服务 | 职责 | 业务价值 |
|------|------|----------|
| `ZulipService` | 游戏登录/登出业务流程协调 | 处理玩家生命周期管理 |
| `SessionManagerService` | 游戏会话状态和上下文管理 | 维护玩家位置和聊天上下文 |
| `MessageFilterService` | 消息过滤和业务规则控制 | 实现内容审核和权限验证 |
| `ZulipWebSocketGateway` | WebSocket业务协议处理 | 游戏协议转换和路由 |
#### 核心服务层服务
| 服务 | 职责 | 技术价值 |
|------|------|----------|
| `ZulipClientService` | Zulip REST API封装 | 第三方API调用抽象 |
| `ZulipClientPoolService` | 客户端连接池管理 | 资源管理和性能优化 |
| `ConfigManagerService` | 配置文件管理和热重载 | 系统配置和运维支持 |
| `ZulipEventProcessorService` | 事件队列处理和消息转换 | 异步消息处理机制 |
### 使用示例
#### 业务层调用核心服务
```typescript
@Injectable()
export class ZulipService {
constructor(
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
) {}
async sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse> {
// 业务逻辑:验证和处理
const session = await this.sessionManager.getSession(request.socketId);
const context = await this.sessionManager.injectContext(request.socketId);
// 调用核心服务:技术实现
const result = await this.zulipClientPool.sendMessage(
session.userId,
context.stream,
context.topic,
request.content,
);
return { success: result.success, messageId: result.messageId };
}
}
```
### 迁移指南
如果你的代码中直接导入了已移动的服务,请按以下方式更新:
#### 更新导入路径
```typescript
// ❌ 旧的导入方式
import { ZulipClientPoolService } from './services/zulip_client_pool.service';
// ✅ 新的导入方式(通过依赖注入)
import { IZulipClientPoolService } from '../../core/zulip/interfaces/zulip-core.interfaces';
constructor(
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
) {}
```
#### 更新模块导入
```typescript
// ✅ 业务模块自动导入核心模块
@Module({
imports: [
ZulipCoreModule, // 自动提供所有核心服务
// ...
],
})
export class ZulipModule {}
```
### 测试策略
#### 业务逻辑测试
```typescript
// 使用Mock核心服务测试业务逻辑
const mockZulipClientPool: IZulipClientPoolService = {
sendMessage: jest.fn().mockResolvedValue({ success: true }),
// ...
};
const module = await Test.createTestingModule({
providers: [
ZulipService,
{ provide: 'ZULIP_CLIENT_POOL_SERVICE', useValue: mockZulipClientPool },
],
}).compile();
```
#### 核心服务测试
```typescript
// 独立测试技术实现
describe('ZulipClientService', () => {
it('should call Zulip API correctly', async () => {
// 测试API调用逻辑
});
});
```
这种架构设计确保了业务逻辑与技术实现的清晰分离,提高了代码的可维护性和可测试性。

View File

@@ -1,13 +0,0 @@
/**
* Zulip配置模块导出
*
* 功能描述:
* - 统一导出所有Zulip配置相关的接口和函数
* - 提供配置加载和验证功能
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
export * from './zulip.config';

View File

@@ -1,397 +0,0 @@
/**
* Zulip配置模块
*
* 功能描述:
* - 定义Zulip集成系统的配置接口
* - 提供配置验证功能
* - 支持环境变量和配置文件两种配置方式
* - 实现配置热重载
*
* 配置来源优先级:
* 1. 环境变量(最高优先级)
* 2. 配置文件
* 3. 默认值(最低优先级)
*
* 依赖模块:
* - @nestjs/config: NestJS配置模块
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { registerAs } from '@nestjs/config';
/**
* Zulip服务器配置接口
*/
export interface ZulipServerConfig {
/** Zulip服务器URL */
serverUrl: string;
/** Zulip机器人邮箱 */
botEmail: string;
/** Zulip机器人API Key */
botApiKey: string;
}
/**
* WebSocket配置接口
*/
export interface WebSocketConfig {
/** WebSocket端口 */
port: number;
/** WebSocket命名空间 */
namespace: string;
/** 心跳间隔(毫秒) */
pingInterval: number;
/** 心跳超时(毫秒) */
pingTimeout: number;
}
/**
* 消息配置接口
*/
export interface MessageConfig {
/** 消息频率限制(条/分钟) */
rateLimit: number;
/** 消息最大长度 */
maxLength: number;
/** 是否启用内容过滤 */
contentFilterEnabled: boolean;
/** 敏感词列表文件路径 */
sensitiveWordsPath?: string;
}
/**
* 会话配置接口
*/
export interface SessionConfig {
/** 会话超时时间(分钟) */
timeout: number;
/** 清理间隔(分钟) */
cleanupInterval: number;
/** 最大连接数 */
maxConnections: number;
}
/**
* 错误处理配置接口
*/
export interface ErrorHandlingConfig {
/** 是否启用降级模式 */
degradedModeEnabled: boolean;
/** 是否启用自动重连 */
autoReconnectEnabled: boolean;
/** 最大重连尝试次数 */
maxReconnectAttempts: number;
/** 重连基础延迟(毫秒) */
reconnectBaseDelay: number;
/** API超时时间毫秒 */
apiTimeout: number;
/** 最大重试次数 */
maxRetries: number;
}
/**
* 监控配置接口
*/
export interface MonitoringConfig {
/** 健康检查间隔(毫秒) */
healthCheckInterval: number;
/** 错误率阈值0-1 */
errorRateThreshold: number;
/** API响应时间阈值毫秒 */
responseTimeThreshold: number;
/** 内存使用阈值0-1 */
memoryThreshold: number;
}
/**
* 安全配置接口
*/
export interface SecurityConfig {
/** API Key加密密钥生产环境必须配置 */
apiKeyEncryptionKey?: string;
/** 允许的Stream列表空表示允许所有 */
allowedStreams: string[];
}
/**
* 完整的Zulip配置接口
*/
export interface ZulipConfiguration {
/** Zulip服务器配置 */
server: ZulipServerConfig;
/** WebSocket配置 */
websocket: WebSocketConfig;
/** 消息配置 */
message: MessageConfig;
/** 会话配置 */
session: SessionConfig;
/** 错误处理配置 */
errorHandling: ErrorHandlingConfig;
/** 监控配置 */
monitoring: MonitoringConfig;
/** 安全配置 */
security: SecurityConfig;
}
/**
* 配置验证结果接口
*/
export interface ConfigValidationResult {
/** 是否有效 */
valid: boolean;
/** 错误信息列表 */
errors: string[];
/** 警告信息列表 */
warnings: string[];
}
/**
* 默认配置值
*/
export const DEFAULT_ZULIP_CONFIG: ZulipConfiguration = {
server: {
serverUrl: 'https://your-zulip-server.com',
botEmail: 'bot@example.com',
botApiKey: '',
},
websocket: {
port: 3000,
namespace: '/game',
pingInterval: 25000,
pingTimeout: 5000,
},
message: {
rateLimit: 10,
maxLength: 10000,
contentFilterEnabled: true,
},
session: {
timeout: 30,
cleanupInterval: 5,
maxConnections: 1000,
},
errorHandling: {
degradedModeEnabled: false,
autoReconnectEnabled: true,
maxReconnectAttempts: 5,
reconnectBaseDelay: 5000,
apiTimeout: 30000,
maxRetries: 3,
},
monitoring: {
healthCheckInterval: 60000,
errorRateThreshold: 0.1,
responseTimeThreshold: 5000,
memoryThreshold: 0.9,
},
security: {
allowedStreams: [],
},
};
/**
* 从环境变量加载Zulip配置
*
* 功能描述:
* 从环境变量读取配置值,未设置的使用默认值
*
* @returns ZulipConfiguration 完整的Zulip配置对象
*/
export function loadZulipConfigFromEnv(): ZulipConfiguration {
return {
server: {
serverUrl: process.env.ZULIP_SERVER_URL || DEFAULT_ZULIP_CONFIG.server.serverUrl,
botEmail: process.env.ZULIP_BOT_EMAIL || DEFAULT_ZULIP_CONFIG.server.botEmail,
botApiKey: process.env.ZULIP_BOT_API_KEY || DEFAULT_ZULIP_CONFIG.server.botApiKey,
},
websocket: {
port: parseInt(process.env.WEBSOCKET_PORT || String(DEFAULT_ZULIP_CONFIG.websocket.port), 10),
namespace: process.env.WEBSOCKET_NAMESPACE || DEFAULT_ZULIP_CONFIG.websocket.namespace,
pingInterval: parseInt(process.env.WEBSOCKET_PING_INTERVAL || String(DEFAULT_ZULIP_CONFIG.websocket.pingInterval), 10),
pingTimeout: parseInt(process.env.WEBSOCKET_PING_TIMEOUT || String(DEFAULT_ZULIP_CONFIG.websocket.pingTimeout), 10),
},
message: {
rateLimit: parseInt(process.env.ZULIP_MESSAGE_RATE_LIMIT || String(DEFAULT_ZULIP_CONFIG.message.rateLimit), 10),
maxLength: parseInt(process.env.ZULIP_MESSAGE_MAX_LENGTH || String(DEFAULT_ZULIP_CONFIG.message.maxLength), 10),
contentFilterEnabled: process.env.ZULIP_CONTENT_FILTER_ENABLED !== 'false',
sensitiveWordsPath: process.env.ZULIP_SENSITIVE_WORDS_PATH,
},
session: {
timeout: parseInt(process.env.ZULIP_SESSION_TIMEOUT || String(DEFAULT_ZULIP_CONFIG.session.timeout), 10),
cleanupInterval: parseInt(process.env.ZULIP_CLEANUP_INTERVAL || String(DEFAULT_ZULIP_CONFIG.session.cleanupInterval), 10),
maxConnections: parseInt(process.env.ZULIP_MAX_CONNECTIONS || String(DEFAULT_ZULIP_CONFIG.session.maxConnections), 10),
},
errorHandling: {
degradedModeEnabled: process.env.ZULIP_DEGRADED_MODE_ENABLED === 'true',
autoReconnectEnabled: process.env.ZULIP_AUTO_RECONNECT_ENABLED !== 'false',
maxReconnectAttempts: parseInt(process.env.ZULIP_MAX_RECONNECT_ATTEMPTS || String(DEFAULT_ZULIP_CONFIG.errorHandling.maxReconnectAttempts), 10),
reconnectBaseDelay: parseInt(process.env.ZULIP_RECONNECT_BASE_DELAY || String(DEFAULT_ZULIP_CONFIG.errorHandling.reconnectBaseDelay), 10),
apiTimeout: parseInt(process.env.ZULIP_API_TIMEOUT || String(DEFAULT_ZULIP_CONFIG.errorHandling.apiTimeout), 10),
maxRetries: parseInt(process.env.ZULIP_MAX_RETRIES || String(DEFAULT_ZULIP_CONFIG.errorHandling.maxRetries), 10),
},
monitoring: {
healthCheckInterval: parseInt(process.env.MONITORING_HEALTH_CHECK_INTERVAL || String(DEFAULT_ZULIP_CONFIG.monitoring.healthCheckInterval), 10),
errorRateThreshold: parseFloat(process.env.MONITORING_ERROR_RATE_THRESHOLD || String(DEFAULT_ZULIP_CONFIG.monitoring.errorRateThreshold)),
responseTimeThreshold: parseInt(process.env.MONITORING_RESPONSE_TIME_THRESHOLD || String(DEFAULT_ZULIP_CONFIG.monitoring.responseTimeThreshold), 10),
memoryThreshold: parseFloat(process.env.MONITORING_MEMORY_THRESHOLD || String(DEFAULT_ZULIP_CONFIG.monitoring.memoryThreshold)),
},
security: {
apiKeyEncryptionKey: process.env.ZULIP_API_KEY_ENCRYPTION_KEY,
allowedStreams: (process.env.ZULIP_ALLOWED_STREAMS || '').split(',').filter(s => s.trim()),
},
};
}
/**
* 验证Zulip配置
*
* 功能描述:
* 验证配置的完整性和有效性,返回验证结果
*
* 验证规则:
* 1. 必填字段不能为空
* 2. 数值字段必须在有效范围内
* 3. URL格式必须正确
* 4. 生产环境必须配置API Key加密密钥
*
* @param config Zulip配置对象
* @param isProduction 是否为生产环境
* @returns ConfigValidationResult 验证结果
*/
export function validateZulipConfig(
config: ZulipConfiguration,
isProduction: boolean = false
): ConfigValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// 验证服务器配置
if (!config.server.serverUrl) {
errors.push('缺少Zulip服务器URL (ZULIP_SERVER_URL)');
} else if (!isValidUrl(config.server.serverUrl)) {
errors.push('Zulip服务器URL格式无效');
}
if (!config.server.botEmail) {
warnings.push('未配置Zulip机器人邮箱 (ZULIP_BOT_EMAIL)');
} else if (!isValidEmail(config.server.botEmail)) {
errors.push('Zulip机器人邮箱格式无效');
}
if (!config.server.botApiKey) {
warnings.push('未配置Zulip机器人API Key (ZULIP_BOT_API_KEY),将使用本地模式');
}
// 验证WebSocket配置
if (config.websocket.port < 1 || config.websocket.port > 65535) {
errors.push('WebSocket端口必须在1-65535范围内');
}
if (!config.websocket.namespace || !config.websocket.namespace.startsWith('/')) {
errors.push('WebSocket命名空间必须以/开头');
}
// 验证消息配置
if (config.message.rateLimit < 1) {
errors.push('消息频率限制必须大于0');
}
if (config.message.maxLength < 1 || config.message.maxLength > 100000) {
errors.push('消息最大长度必须在1-100000范围内');
}
// 验证会话配置
if (config.session.timeout < 1) {
errors.push('会话超时时间必须大于0');
}
if (config.session.cleanupInterval < 1) {
errors.push('清理间隔必须大于0');
}
if (config.session.maxConnections < 1) {
errors.push('最大连接数必须大于0');
}
// 验证错误处理配置
if (config.errorHandling.maxReconnectAttempts < 0) {
errors.push('最大重连尝试次数不能为负数');
}
if (config.errorHandling.apiTimeout < 1000) {
warnings.push('API超时时间过短建议至少1000毫秒');
}
// 验证监控配置
if (config.monitoring.errorRateThreshold < 0 || config.monitoring.errorRateThreshold > 1) {
errors.push('错误率阈值必须在0-1范围内');
}
if (config.monitoring.memoryThreshold < 0 || config.monitoring.memoryThreshold > 1) {
errors.push('内存使用阈值必须在0-1范围内');
}
// 生产环境特殊验证
if (isProduction) {
if (!config.security.apiKeyEncryptionKey) {
errors.push('生产环境必须配置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)');
} else if (config.security.apiKeyEncryptionKey.length < 32) {
errors.push('API Key加密密钥长度必须至少32字符');
}
if (!config.server.botApiKey) {
errors.push('生产环境必须配置Zulip机器人API Key');
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* 验证URL格式
*
* @param url URL字符串
* @returns boolean 是否有效
*/
function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* 验证邮箱格式
*
* @param email 邮箱字符串
* @returns boolean 是否有效
*/
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* NestJS配置工厂函数
*
* 功能描述:
* 用于@nestjs/config模块的配置注册
*
* 使用方式:
* ConfigModule.forRoot({
* load: [zulipConfig],
* })
*/
export const zulipConfig = registerAs('zulip', () => {
return loadZulipConfigFromEnv();
});

View File

@@ -1,515 +0,0 @@
/**
* Zulip集成系统接口定义
*
* 功能描述:
* - 定义Zulip集成系统中使用的所有接口和类型
* - 提供类型安全和代码提示支持
* - 统一数据结构定义
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
/**
* 游戏协议消息接口
*/
export namespace GameProtocol {
/**
* 登录消息接口
*/
export interface LoginMessage {
type: 'login';
token: string;
}
/**
* 聊天消息接口
*/
export interface ChatMessage {
t: 'chat';
content: string;
scope: string; // "local" 或 topic名称
}
/**
* 位置更新消息接口
*/
export interface PositionMessage {
t: 'position';
x: number;
y: number;
mapId: string;
}
/**
* 聊天渲染消息接口 - 发送给客户端
*/
export interface ChatRenderMessage {
t: 'chat_render';
from: string;
txt: string;
bubble: boolean;
}
/**
* 登录成功消息接口
*/
export interface LoginSuccessMessage {
t: 'login_success';
sessionId: string;
currentMap: string;
}
/**
* 错误消息接口
*/
export interface ErrorMessage {
t: 'error';
message: string;
code?: string;
}
}
/**
* Zulip API接口
*/
export namespace ZulipAPI {
/**
* Zulip消息接口
*/
export interface Message {
id: number;
sender_email: string;
sender_full_name: string;
content: string;
stream_id: number;
subject: string;
timestamp: number;
}
/**
* Zulip事件接口
*/
export interface Event {
type: string;
message?: Message;
queue_id: string;
}
/**
* Zulip Stream接口
*/
export interface Stream {
stream_id: number;
name: string;
description: string;
}
/**
* 发送消息请求接口
*/
export interface SendMessageRequest {
type: 'stream';
to: string;
subject: string;
content: string;
}
/**
* 事件队列注册请求接口
*/
export interface RegisterQueueRequest {
event_types: string[];
narrow?: Array<[string, string]>;
}
/**
* 事件队列响应接口
*/
export interface RegisterQueueResponse {
queue_id: string;
last_event_id: number;
}
}
/**
* 系统内部接口
*/
export namespace Internal {
/**
* 位置信息接口
*/
export interface Position {
x: number;
y: number;
}
/**
* 游戏会话接口
*
* 功能描述:
* - 维护WebSocket连接ID与Zulip队列ID的映射关系
* - 跟踪玩家位置和地图信息
* - 支持会话状态的序列化和反序列化
*
* Redis存储结构
* - Key: zulip:session:{socketId}
* - Value: JSON序列化的GameSession对象
* - TTL: 3600秒1小时
*
* @since 2025-12-25
*/
export interface GameSession {
socketId: string; // WebSocket连接ID
userId: string; // 用户ID
username: string; // 用户名
zulipQueueId: string; // Zulip事件队列ID (关键绑定)
currentMap: string; // 当前地图ID
position: Position; // 当前位置
lastActivity: Date; // 最后活动时间
createdAt: Date; // 会话创建时间
}
/**
* 游戏会话序列化格式用于Redis存储
*/
export interface GameSessionSerialized {
socketId: string;
userId: string;
username: string;
zulipQueueId: string;
currentMap: string;
position: Position;
lastActivity: string; // ISO 8601格式的日期字符串
createdAt: string; // ISO 8601格式的日期字符串
}
/**
* 创建会话请求接口
*/
export interface CreateSessionRequest {
socketId: string;
userId: string;
username?: string;
zulipQueueId: string;
initialMap?: string;
initialPosition?: Position;
}
/**
* 会话统计信息接口
*/
export interface SessionStats {
totalSessions: number;
mapDistribution: Record<string, number>;
oldestSession?: Date;
newestSession?: Date;
}
/**
* Zulip客户端接口
*/
export interface ZulipClient {
userId: string;
apiKey: string;
queueId?: string;
client?: any;
createdAt: Date;
lastActivity: Date;
}
/**
* 地图配置接口
*
* 功能描述:
* - 定义游戏地图到Zulip Stream的映射关系
* - 包含地图内的交互对象配置
*
* 验证规则:
* - mapId: 必填,非空字符串,唯一标识
* - mapName: 必填,非空字符串,用于显示
* - zulipStream: 必填非空字符串对应Zulip Stream名称
* - interactionObjects: 可选,交互对象数组
*
* @since 2025-12-25
*/
export interface MapConfig {
mapId: string; // 地图ID (例如: "whale_port")
mapName: string; // 地图名称 (例如: "新手村")
zulipStream: string; // 对应的Zulip Stream (例如: "Whale Port")
description?: string; // 地图描述(可选)
interactionObjects: InteractionObject[]; // 交互对象配置
}
/**
* 交互对象接口
*
* 功能描述:
* - 定义地图内交互对象到Zulip Topic的映射关系
* - 包含对象位置信息用于空间过滤
*
* 验证规则:
* - objectId: 必填,非空字符串,唯一标识
* - objectName: 必填,非空字符串,用于显示
* - zulipTopic: 必填非空字符串对应Zulip Topic名称
* - position: 必填包含有效的x和y坐标
*
* @since 2025-12-25
*/
export interface InteractionObject {
objectId: string; // 对象ID (例如: "notice_board")
objectName: string; // 对象名称 (例如: "公告板")
zulipTopic: string; // 对应的Zulip Topic (例如: "Notice Board")
position: { // 对象位置
x: number;
y: number;
};
}
/**
* 地图配置文件结构接口
*
* 功能描述:
* - 定义配置文件的根结构
* - 用于配置文件的加载和验证
*
* @since 2025-12-25
*/
export interface MapConfigFile {
maps: MapConfig[]; // 地图配置数组
version?: string; // 配置版本(可选)
lastModified?: string; // 最后修改时间(可选)
}
/**
* 配置验证结果接口
*
* 功能描述:
* - 定义配置验证的结果结构
* - 包含验证状态和错误信息
*
* @since 2025-12-25
*/
export interface ConfigValidationResult {
valid: boolean; // 是否有效
errors: string[]; // 错误信息列表
warnings?: string[]; // 警告信息列表(可选)
}
/**
* 配置统计信息接口
*
* 功能描述:
* - 定义配置统计信息的结构
* - 用于监控和调试
*
* @since 2025-12-25
*/
export interface ConfigStats {
mapCount: number; // 地图数量
totalObjects: number; // 交互对象总数
configLoadTime: Date; // 配置加载时间
isValid: boolean; // 配置是否有效
}
/**
* 上下文信息接口
*/
export interface ContextInfo {
stream: string;
topic?: string;
}
/**
* 消息过滤结果接口
*/
export interface ContentFilterResult {
allowed: boolean;
filtered?: string;
reason?: string;
}
/**
* 错误处理结果接口
*/
export interface ErrorHandlingResult {
success: boolean;
shouldRetry: boolean;
retryAfter?: number;
degradedMode?: boolean;
message: string;
}
}
/**
* 服务请求接口
*/
export namespace ServiceRequests {
/**
* 玩家登录请求
*/
export interface PlayerLoginRequest {
token: string;
socketId: string;
}
/**
* 聊天消息请求
*/
export interface ChatMessageRequest {
socketId: string;
content: string;
scope: string;
}
/**
* 位置更新请求
*/
export interface PositionUpdateRequest {
socketId: string;
x: number;
y: number;
mapId: string;
}
}
/**
* 服务响应接口
*/
export namespace ServiceResponses {
/**
* 基础响应接口
*/
export interface BaseResponse {
success: boolean;
error?: string;
}
/**
* 登录响应
*/
export interface LoginResponse extends BaseResponse {
sessionId?: string;
}
/**
* 聊天消息响应
*/
export interface ChatMessageResponse extends BaseResponse {
messageId?: string;
}
}
/**
* 配置接口
*/
export namespace Config {
/**
* Zulip配置接口
*/
export interface ZulipConfig {
zulipServerUrl: string;
zulipBotEmail: string;
zulipBotApiKey: string;
websocketPort: number;
websocketNamespace: string;
messageRateLimit: number;
messageMaxLength: number;
sessionTimeout: number;
cleanupInterval: number;
enableContentFilter: boolean;
allowedStreams: string[];
}
/**
* 重试配置接口
*/
export interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
backoffMultiplier: number;
}
}
/**
* 枚举定义
*/
export namespace Enums {
/**
* 服务状态枚举
*/
export enum ServiceStatus {
NORMAL = 'normal',
DEGRADED = 'degraded',
UNAVAILABLE = 'unavailable',
}
/**
* 错误类型枚举
*/
export enum ErrorType {
ZULIP_API_ERROR = 'zulip_api_error',
CONNECTION_ERROR = 'connection_error',
TIMEOUT_ERROR = 'timeout_error',
AUTHENTICATION_ERROR = 'authentication_error',
RATE_LIMIT_ERROR = 'rate_limit_error',
UNKNOWN_ERROR = 'unknown_error',
}
/**
* 违规类型枚举
*/
export enum ViolationType {
CONTENT = 'content',
RATE = 'rate',
PERMISSION = 'permission',
}
/**
* 消息范围枚举
*/
export enum MessageScope {
LOCAL = 'local',
GLOBAL = 'global',
TOPIC = 'topic',
}
}
/**
* 常量定义
*/
export namespace Constants {
/**
* Redis键前缀
*/
export const REDIS_PREFIXES = {
SESSION: 'zulip:session:',
MAP_PLAYERS: 'zulip:map_players:',
RATE_LIMIT: 'zulip:rate_limit:',
VIOLATION: 'zulip:violation:',
} as const;
/**
* 默认配置值
*/
export const DEFAULTS = {
SESSION_TIMEOUT: 3600, // 1小时
RATE_LIMIT: 10, // 每分钟10条消息
RATE_LIMIT_WINDOW: 60, // 60秒窗口
MESSAGE_MAX_LENGTH: 1000,
RETRY_MAX_ATTEMPTS: 3,
RETRY_BASE_DELAY: 1000,
WEBSOCKET_NAMESPACE: '/game',
} as const;
/**
* 默认地图配置
*/
export const DEFAULT_MAPS = {
NOVICE_VILLAGE: 'novice_village',
TAVERN: 'tavern',
MARKET: 'market',
} as const;
}

View File

@@ -1,551 +0,0 @@
/**
* API Key安全存储服务测试
*
* 功能描述:
* - 测试ApiKeySecurityService的核心功能
* - 包含属性测试验证API Key安全存储
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import * as fc from 'fast-check';
import {
ApiKeySecurityService,
SecurityEventType,
SecuritySeverity,
} from './api-key-security.service';
import { IRedisService } from '../../../core/redis/redis.interface';
describe('ApiKeySecurityService', () => {
let service: ApiKeySecurityService;
let mockRedisService: jest.Mocked<IRedisService>;
// 内存存储模拟Redis
let memoryStore: Map<string, { value: string; expireAt?: number }>;
beforeEach(async () => {
jest.clearAllMocks();
// Mock NestJS Logger
jest.spyOn(Logger.prototype, 'log').mockImplementation();
jest.spyOn(Logger.prototype, 'error').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
// 初始化内存存储
memoryStore = new Map();
// 创建模拟Redis服务
mockRedisService = {
set: jest.fn().mockImplementation(async (key: string, value: string, ttl?: number) => {
memoryStore.set(key, {
value,
expireAt: ttl ? Date.now() + ttl * 1000 : undefined,
});
}),
setex: jest.fn().mockImplementation(async (key: string, ttl: number, value: string) => {
memoryStore.set(key, {
value,
expireAt: Date.now() + ttl * 1000,
});
}),
get: jest.fn().mockImplementation(async (key: string) => {
const item = memoryStore.get(key);
if (!item) return null;
if (item.expireAt && item.expireAt <= Date.now()) {
memoryStore.delete(key);
return null;
}
return item.value;
}),
del: jest.fn().mockImplementation(async (key: string) => {
const existed = memoryStore.has(key);
memoryStore.delete(key);
return existed;
}),
exists: jest.fn().mockImplementation(async (key: string) => {
return memoryStore.has(key);
}),
ttl: jest.fn().mockImplementation(async (key: string) => {
const item = memoryStore.get(key);
if (!item || !item.expireAt) return -1;
return Math.max(0, Math.floor((item.expireAt - Date.now()) / 1000));
}),
incr: jest.fn().mockImplementation(async (key: string) => {
const item = memoryStore.get(key);
if (!item) {
memoryStore.set(key, { value: '1' });
return 1;
}
const newValue = parseInt(item.value, 10) + 1;
item.value = newValue.toString();
return newValue;
}),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeySecurityService,
{
provide: 'REDIS_SERVICE',
useValue: mockRedisService,
},
],
}).compile();
service = module.get<ApiKeySecurityService>(ApiKeySecurityService);
});
afterEach(async () => {
memoryStore.clear();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('storeApiKey - 存储API Key', () => {
it('应该成功存储有效的API Key', async () => {
const result = await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
expect(result.success).toBe(true);
expect(result.userId).toBe('user-123');
});
it('应该拒绝空用户ID', async () => {
const result = await service.storeApiKey('', 'abcdefghijklmnopqrstuvwxyz123456');
expect(result.success).toBe(false);
expect(result.message).toContain('用户ID');
});
it('应该拒绝空API Key', async () => {
const result = await service.storeApiKey('user-123', '');
expect(result.success).toBe(false);
expect(result.message).toContain('API Key');
});
it('应该拒绝格式无效的API Key', async () => {
const result = await service.storeApiKey('user-123', 'short');
expect(result.success).toBe(false);
expect(result.message).toContain('格式无效');
});
it('应该拒绝包含特殊字符的API Key', async () => {
const result = await service.storeApiKey('user-123', 'invalid!@#$%^&*()key12345');
expect(result.success).toBe(false);
expect(result.message).toContain('格式无效');
});
});
describe('getApiKey - 获取API Key', () => {
it('应该成功获取已存储的API Key', async () => {
const apiKey = 'abcdefghijklmnopqrstuvwxyz123456';
await service.storeApiKey('user-123', apiKey);
const result = await service.getApiKey('user-123');
expect(result.success).toBe(true);
expect(result.apiKey).toBe(apiKey);
});
it('应该返回不存在的API Key错误', async () => {
const result = await service.getApiKey('nonexistent-user');
expect(result.success).toBe(false);
expect(result.message).toContain('不存在');
});
it('应该拒绝空用户ID', async () => {
const result = await service.getApiKey('');
expect(result.success).toBe(false);
expect(result.message).toContain('用户ID');
});
});
describe('updateApiKey - 更新API Key', () => {
it('应该成功更新已存在的API Key', async () => {
const oldKey = 'abcdefghijklmnopqrstuvwxyz123456';
const newKey = 'newkeyabcdefghijklmnopqrstuvwx';
await service.storeApiKey('user-123', oldKey);
const updateResult = await service.updateApiKey('user-123', newKey);
expect(updateResult.success).toBe(true);
const getResult = await service.getApiKey('user-123');
expect(getResult.apiKey).toBe(newKey);
});
it('应该为不存在的用户创建新的API Key', async () => {
const newKey = 'newkeyabcdefghijklmnopqrstuvwx';
const result = await service.updateApiKey('new-user', newKey);
expect(result.success).toBe(true);
const getResult = await service.getApiKey('new-user');
expect(getResult.apiKey).toBe(newKey);
});
});
describe('deleteApiKey - 删除API Key', () => {
it('应该成功删除已存在的API Key', async () => {
await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
const deleteResult = await service.deleteApiKey('user-123');
expect(deleteResult).toBe(true);
const getResult = await service.getApiKey('user-123');
expect(getResult.success).toBe(false);
});
it('应该对不存在的API Key返回成功', async () => {
const result = await service.deleteApiKey('nonexistent-user');
expect(result).toBe(true);
});
});
describe('hasApiKey - 检查API Key存在性', () => {
it('应该返回true当API Key存在时', async () => {
await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
const result = await service.hasApiKey('user-123');
expect(result).toBe(true);
});
it('应该返回false当API Key不存在时', async () => {
const result = await service.hasApiKey('nonexistent-user');
expect(result).toBe(false);
});
});
describe('logSecurityEvent - 记录安全事件', () => {
it('应该成功记录安全事件', async () => {
await service.logSecurityEvent({
eventType: SecurityEventType.API_KEY_STORED,
severity: SecuritySeverity.INFO,
userId: 'user-123',
details: { action: 'test' },
timestamp: new Date(),
});
expect(mockRedisService.setex).toHaveBeenCalled();
expect(Logger.prototype.log).toHaveBeenCalled();
});
it('应该根据严重级别使用不同的日志级别', async () => {
// 测试WARNING级别
await service.logSecurityEvent({
eventType: SecurityEventType.SUSPICIOUS_ACCESS,
severity: SecuritySeverity.WARNING,
userId: 'user-123',
details: { reason: 'test' },
timestamp: new Date(),
});
expect(Logger.prototype.warn).toHaveBeenCalled();
// 测试CRITICAL级别
await service.logSecurityEvent({
eventType: SecurityEventType.API_KEY_DECRYPTION_FAILED,
severity: SecuritySeverity.CRITICAL,
userId: 'user-123',
details: { error: 'test' },
timestamp: new Date(),
});
expect(Logger.prototype.error).toHaveBeenCalled();
});
});
describe('getApiKeyStats - 获取API Key统计信息', () => {
it('应该返回正确的统计信息', async () => {
await service.storeApiKey('user-123', 'abcdefghijklmnopqrstuvwxyz123456');
await service.getApiKey('user-123');
await service.getApiKey('user-123');
const stats = await service.getApiKeyStats('user-123');
expect(stats.exists).toBe(true);
expect(stats.accessCount).toBe(2);
expect(stats.createdAt).toBeDefined();
});
it('应该返回不存在状态', async () => {
const stats = await service.getApiKeyStats('nonexistent-user');
expect(stats.exists).toBe(false);
});
});
/**
* 属性测试: API Key安全存储
*
* **Feature: zulip-integration, Property 8: API Key安全存储**
* **Validates: Requirements 7.1, 7.3**
*
* 对于任何用户的Zulip API Key系统应该使用加密方式存储在数据库中
* 并在检测到异常操作时记录安全日志
*/
describe('Property 8: API Key安全存储', () => {
/**
* 属性: 对于任何有效的API Key存储后应该能够正确获取
* 验证需求 7.1: 存储Zulip API Key时系统应使用加密方式存储
*/
it('对于任何有效的API Key存储后应该能够正确获取', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的API Key16-64字符的字母数字字符串
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
async (userId, apiKey) => {
// 清理之前的数据
memoryStore.clear();
// 存储API Key
const storeResult = await service.storeApiKey(userId.trim(), apiKey);
expect(storeResult.success).toBe(true);
// 获取API Key
const getResult = await service.getApiKey(userId.trim());
expect(getResult.success).toBe(true);
expect(getResult.apiKey).toBe(apiKey);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何存储的API Key存储的数据应该是加密的不等于原始值
* 验证需求 7.1: 存储Zulip API Key时系统应使用加密方式存储
*/
it('对于任何存储的API Key存储的数据应该是加密的', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的API Key
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
async (userId, apiKey) => {
// 清理之前的数据
memoryStore.clear();
// 存储API Key
await service.storeApiKey(userId.trim(), apiKey);
// 检查存储的数据
const storageKey = `zulip:api_key:${userId.trim()}`;
const storedData = memoryStore.get(storageKey);
expect(storedData).toBeDefined();
// 解析存储的数据
const parsedData = JSON.parse(storedData!.value);
// 验证存储的是加密数据不是原始API Key
expect(parsedData.encryptedKey).toBeDefined();
expect(parsedData.encryptedKey).not.toBe(apiKey);
expect(parsedData.iv).toBeDefined();
expect(parsedData.authTag).toBeDefined();
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何无效格式的API Key存储应该失败
* 验证需求 7.1: API Key格式验证
*/
it('对于任何无效格式的API Key存储应该失败', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成无效的API Key太短或包含特殊字符
fc.oneof(
fc.string({ minLength: 0, maxLength: 15 }), // 太短
fc.string({ minLength: 1, maxLength: 50 }).filter(s => /[!@#$%^&*()+=\[\]{}|;:'",.<>?/\\`~]/.test(s)) // 包含特殊字符
),
async (userId, invalidApiKey) => {
// 清理之前的数据
memoryStore.clear();
// 尝试存储无效的API Key
const result = await service.storeApiKey(userId.trim(), invalidApiKey);
// 应该失败
expect(result.success).toBe(false);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何API Key操作应该记录安全日志
* 验证需求 7.3: 检测到异常操作时系统应记录安全日志
*/
it('对于任何API Key操作应该记录安全日志', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的API Key
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
async (userId, apiKey) => {
// 清理之前的数据和mock调用
memoryStore.clear();
mockRedisService.setex.mockClear();
// 存储API Key
await service.storeApiKey(userId.trim(), apiKey);
// 验证安全日志被记录setex被调用用于存储安全日志
const setexCalls = mockRedisService.setex.mock.calls;
const securityLogCalls = setexCalls.filter(
call => call[0].includes('security_log')
);
expect(securityLogCalls.length).toBeGreaterThan(0);
}
),
{ numRuns: 50 }
);
}, 60000);
/**
* 属性: 对于任何用户更新API Key后应该返回新的Key
* 验证需求 7.1: API Key更新功能
*/
it('对于任何用户更新API Key后应该返回新的Key', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成两个不同的有效API Key
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
async (userId, oldApiKey, newApiKey) => {
// 清理之前的数据
memoryStore.clear();
// 存储原始API Key
await service.storeApiKey(userId.trim(), oldApiKey);
// 更新API Key
const updateResult = await service.updateApiKey(userId.trim(), newApiKey);
expect(updateResult.success).toBe(true);
// 获取API Key应该返回新的Key
const getResult = await service.getApiKey(userId.trim());
expect(getResult.success).toBe(true);
expect(getResult.apiKey).toBe(newApiKey);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何删除操作删除后API Key应该不存在
* 验证需求 7.1: API Key删除功能
*/
it('对于任何删除操作删除后API Key应该不存在', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的API Key
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
async (userId, apiKey) => {
// 清理之前的数据
memoryStore.clear();
// 存储API Key
await service.storeApiKey(userId.trim(), apiKey);
// 验证存在
const existsBefore = await service.hasApiKey(userId.trim());
expect(existsBefore).toBe(true);
// 删除API Key
const deleteResult = await service.deleteApiKey(userId.trim());
expect(deleteResult).toBe(true);
// 验证不存在
const existsAfter = await service.hasApiKey(userId.trim());
expect(existsAfter).toBe(false);
// 获取应该失败
const getResult = await service.getApiKey(userId.trim());
expect(getResult.success).toBe(false);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 加密和解密应该是可逆的
* 验证需求 7.1: 加密存储的正确性
*/
it('加密和解密应该是可逆的', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的API Key
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
async (userId, apiKey) => {
// 清理之前的数据
memoryStore.clear();
// 存储API Key
const storeResult = await service.storeApiKey(userId.trim(), apiKey);
expect(storeResult.success).toBe(true);
// 多次获取应该返回相同的值
const result1 = await service.getApiKey(userId.trim());
const result2 = await service.getApiKey(userId.trim());
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
expect(result1.apiKey).toBe(result2.apiKey);
expect(result1.apiKey).toBe(apiKey);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 访问计数应该正确递增
* 验证需求 7.3: 监控API Key访问
*/
it('访问计数应该正确递增', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的API Key
fc.stringMatching(/^[a-zA-Z0-9_-]{16,64}$/),
// 生成访问次数1-10次
fc.integer({ min: 1, max: 10 }),
async (userId, apiKey, accessCount) => {
// 清理之前的数据
memoryStore.clear();
// 存储API Key
await service.storeApiKey(userId.trim(), apiKey);
// 多次访问
for (let i = 0; i < accessCount; i++) {
await service.getApiKey(userId.trim());
}
// 检查统计信息
const stats = await service.getApiKeyStats(userId.trim());
expect(stats.exists).toBe(true);
expect(stats.accessCount).toBe(accessCount);
}
),
{ numRuns: 50 }
);
}, 60000);
});
});

View File

@@ -1,799 +0,0 @@
/**
* API Key安全存储服务
*
* 功能描述:
* - 实现Zulip API Key的加密存储
* - 提供安全日志记录功能
* - 检测异常操作并记录安全事件
* - 支持API Key的安全获取和更新
*
* 主要方法:
* - storeApiKey(): 加密存储API Key
* - getApiKey(): 安全获取API Key
* - updateApiKey(): 更新API Key
* - deleteApiKey(): 删除API Key
* - logSecurityEvent(): 记录安全事件
*
* 使用场景:
* - 用户首次绑定Zulip账户
* - Zulip客户端创建时获取API Key
* - 检测到异常操作时记录安全日志
*
* 依赖模块:
* - AppLoggerService: 日志记录服务
* - IRedisService: Redis缓存服务
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Injectable, Inject, Logger } from '@nestjs/common';
import * as crypto from 'crypto';
import { IRedisService } from '../../../core/redis/redis.interface';
/**
* 安全事件类型枚举
*/
export enum SecurityEventType {
API_KEY_STORED = 'api_key_stored',
API_KEY_ACCESSED = 'api_key_accessed',
API_KEY_UPDATED = 'api_key_updated',
API_KEY_DELETED = 'api_key_deleted',
API_KEY_DECRYPTION_FAILED = 'api_key_decryption_failed',
SUSPICIOUS_ACCESS = 'suspicious_access',
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
INVALID_KEY_FORMAT = 'invalid_key_format',
UNAUTHORIZED_ACCESS = 'unauthorized_access',
}
/**
* 安全事件严重级别
*/
export enum SecuritySeverity {
INFO = 'info',
WARNING = 'warning',
CRITICAL = 'critical',
}
/**
* 安全事件记录接口
*/
export interface SecurityEvent {
eventType: SecurityEventType;
severity: SecuritySeverity;
userId: string;
details: Record<string, any>;
timestamp: Date;
ipAddress?: string;
userAgent?: string;
}
/**
* 加密后的API Key存储结构
*/
export interface EncryptedApiKey {
encryptedKey: string;
iv: string;
authTag: string;
createdAt: Date;
updatedAt: Date;
accessCount: number;
lastAccessedAt?: Date;
}
/**
* API Key存储结果
*/
export interface StoreApiKeyResult {
success: boolean;
message: string;
userId?: string;
}
/**
* API Key获取结果
*/
export interface GetApiKeyResult {
success: boolean;
apiKey?: string;
message?: string;
}
@Injectable()
export class ApiKeySecurityService {
private readonly logger = new Logger(ApiKeySecurityService.name);
private readonly API_KEY_PREFIX = 'zulip:api_key:';
private readonly SECURITY_LOG_PREFIX = 'zulip:security_log:';
private readonly ACCESS_COUNT_PREFIX = 'zulip:api_key_access:';
private readonly ENCRYPTION_ALGORITHM = 'aes-256-gcm';
private readonly KEY_LENGTH = 32; // 256 bits
private readonly IV_LENGTH = 16; // 128 bits
private readonly AUTH_TAG_LENGTH = 16; // 128 bits
private readonly MAX_ACCESS_PER_MINUTE = 60; // 每分钟最大访问次数
private readonly SECURITY_LOG_RETENTION = 30 * 24 * 3600; // 30天
// 加密密钥(生产环境应从环境变量或密钥管理服务获取)
private readonly encryptionKey: Buffer;
constructor(
@Inject('REDIS_SERVICE')
private readonly redisService: IRedisService,
) {
// 从环境变量获取加密密钥,如果没有则生成一个默认密钥(仅用于开发)
const keyFromEnv = process.env.ZULIP_API_KEY_ENCRYPTION_KEY;
if (keyFromEnv) {
this.encryptionKey = Buffer.from(keyFromEnv, 'hex');
} else {
// 开发环境使用固定密钥(生产环境必须配置环境变量)
this.encryptionKey = crypto.scryptSync('default-dev-key', 'salt', this.KEY_LENGTH);
this.logger.warn('使用默认加密密钥生产环境请配置ZULIP_API_KEY_ENCRYPTION_KEY环境变量');
}
this.logger.log('ApiKeySecurityService初始化完成');
}
/**
* 加密存储API Key
*
* 功能描述:
* 使用AES-256-GCM算法加密API Key并存储到Redis
*
* 业务逻辑:
* 1. 验证API Key格式
* 2. 生成随机IV
* 3. 使用AES-256-GCM加密
* 4. 存储加密后的数据到Redis
* 5. 记录安全日志
*
* @param userId 用户ID
* @param apiKey Zulip API Key
* @param metadata 可选的元数据如IP地址
* @returns Promise<StoreApiKeyResult> 存储结果
*/
async storeApiKey(
userId: string,
apiKey: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<StoreApiKeyResult> {
const startTime = Date.now();
this.logger.log(`开始存储API Key: ${userId}`);
try {
// 1. 参数验证
if (!userId || !userId.trim()) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId: userId || 'unknown',
details: { reason: 'empty_user_id' },
timestamp: new Date(),
...metadata,
});
return { success: false, message: '用户ID不能为空' };
}
if (!apiKey || !apiKey.trim()) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId,
details: { reason: 'empty_api_key' },
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key不能为空' };
}
// 2. 验证API Key格式Zulip API Key通常是32字符的字母数字字符串
if (!this.isValidApiKeyFormat(apiKey)) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId,
details: { reason: 'invalid_format', keyLength: apiKey.length },
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key格式无效' };
}
// 3. 加密API Key
const encrypted = this.encrypt(apiKey);
// 4. 构建存储数据
const storageData: EncryptedApiKey = {
encryptedKey: encrypted.encryptedData,
iv: encrypted.iv,
authTag: encrypted.authTag,
createdAt: new Date(),
updatedAt: new Date(),
accessCount: 0,
};
// 5. 存储到Redis
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
await this.redisService.set(storageKey, JSON.stringify(storageData));
// 6. 记录安全日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_STORED,
severity: SecuritySeverity.INFO,
userId,
details: {
action: 'store',
keyLength: apiKey.length,
},
timestamp: new Date(),
...metadata,
});
const duration = Date.now() - startTime;
this.logger.log(`API Key存储成功: ${userId}`);
return { success: true, message: 'API Key存储成功', userId };
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('API Key存储失败', {
operation: 'storeApiKey',
userId,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, message: '存储失败,请稍后重试' };
}
}
/**
* 安全获取API Key
*
* 功能描述:
* 从Redis获取加密的API Key并解密返回
*
* 业务逻辑:
* 1. 检查访问频率限制
* 2. 从Redis获取加密数据
* 3. 解密API Key
* 4. 更新访问计数
* 5. 记录访问日志
*
* @param userId 用户ID
* @param metadata 可选的元数据
* @returns Promise<GetApiKeyResult> 获取结果
*/
async getApiKey(
userId: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<GetApiKeyResult> {
const startTime = Date.now();
this.logger.debug('开始获取API Key', {
operation: 'getApiKey',
userId,
timestamp: new Date().toISOString(),
});
try {
// 1. 参数验证
if (!userId || !userId.trim()) {
return { success: false, message: '用户ID不能为空' };
}
// 2. 检查访问频率限制
const rateLimitCheck = await this.checkAccessRateLimit(userId);
if (!rateLimitCheck.allowed) {
await this.logSecurityEvent({
eventType: SecurityEventType.RATE_LIMIT_EXCEEDED,
severity: SecuritySeverity.WARNING,
userId,
details: {
currentCount: rateLimitCheck.currentCount,
limit: this.MAX_ACCESS_PER_MINUTE,
},
timestamp: new Date(),
...metadata,
});
return { success: false, message: '访问频率过高,请稍后重试' };
}
// 3. 从Redis获取加密数据
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
const encryptedData = await this.redisService.get(storageKey);
if (!encryptedData) {
this.logger.debug('API Key不存在', {
operation: 'getApiKey',
userId,
});
return { success: false, message: 'API Key不存在' };
}
// 4. 解析存储数据
const storageData: EncryptedApiKey = JSON.parse(encryptedData);
// 5. 解密API Key
let apiKey: string;
try {
apiKey = this.decrypt(
storageData.encryptedKey,
storageData.iv,
storageData.authTag
);
} catch (decryptError) {
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_DECRYPTION_FAILED,
severity: SecuritySeverity.CRITICAL,
userId,
details: {
error: (decryptError as Error).message,
},
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key解密失败' };
}
// 6. 更新访问计数和时间
storageData.accessCount += 1;
storageData.lastAccessedAt = new Date();
await this.redisService.set(storageKey, JSON.stringify(storageData));
// 7. 记录访问日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_ACCESSED,
severity: SecuritySeverity.INFO,
userId,
details: {
accessCount: storageData.accessCount,
},
timestamp: new Date(),
...metadata,
});
const duration = Date.now() - startTime;
this.logger.debug('API Key获取成功', {
operation: 'getApiKey',
userId,
accessCount: storageData.accessCount,
duration,
});
return { success: true, apiKey };
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('API Key获取失败', {
operation: 'getApiKey',
userId,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, message: '获取失败,请稍后重试' };
}
}
/**
* 更新API Key
*
* 功能描述:
* 更新用户的Zulip API Key
*
* @param userId 用户ID
* @param newApiKey 新的API Key
* @param metadata 可选的元数据
* @returns Promise<StoreApiKeyResult> 更新结果
*/
async updateApiKey(
userId: string,
newApiKey: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<StoreApiKeyResult> {
this.logger.log(`开始更新API Key: ${userId}`);
try {
// 1. 检查原API Key是否存在
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
const existingData = await this.redisService.get(storageKey);
if (!existingData) {
// 如果不存在,则创建新的
return this.storeApiKey(userId, newApiKey, metadata);
}
// 2. 验证新API Key格式
if (!this.isValidApiKeyFormat(newApiKey)) {
await this.logSecurityEvent({
eventType: SecurityEventType.INVALID_KEY_FORMAT,
severity: SecuritySeverity.WARNING,
userId,
details: { reason: 'invalid_format', action: 'update' },
timestamp: new Date(),
...metadata,
});
return { success: false, message: 'API Key格式无效' };
}
// 3. 解析现有数据
const oldStorageData: EncryptedApiKey = JSON.parse(existingData);
// 4. 加密新API Key
const encrypted = this.encrypt(newApiKey);
// 5. 更新存储数据
const newStorageData: EncryptedApiKey = {
encryptedKey: encrypted.encryptedData,
iv: encrypted.iv,
authTag: encrypted.authTag,
createdAt: oldStorageData.createdAt,
updatedAt: new Date(),
accessCount: oldStorageData.accessCount,
lastAccessedAt: oldStorageData.lastAccessedAt,
};
await this.redisService.set(storageKey, JSON.stringify(newStorageData));
// 6. 记录安全日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_UPDATED,
severity: SecuritySeverity.INFO,
userId,
details: {
action: 'update',
previousAccessCount: oldStorageData.accessCount,
},
timestamp: new Date(),
...metadata,
});
this.logger.log(`API Key更新成功: ${userId}`);
return { success: true, message: 'API Key更新成功', userId };
} catch (error) {
const err = error as Error;
this.logger.error('API Key更新失败', {
operation: 'updateApiKey',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, message: '更新失败,请稍后重试' };
}
}
/**
* 删除API Key
*
* 功能描述:
* 安全删除用户的API Key
*
* @param userId 用户ID
* @param metadata 可选的元数据
* @returns Promise<boolean> 是否删除成功
*/
async deleteApiKey(
userId: string,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<boolean> {
this.logger.log(`开始删除API Key: ${userId}`);
try {
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
await this.redisService.del(storageKey);
// 记录安全日志
await this.logSecurityEvent({
eventType: SecurityEventType.API_KEY_DELETED,
severity: SecuritySeverity.INFO,
userId,
details: { action: 'delete' },
timestamp: new Date(),
...metadata,
});
this.logger.log(`API Key删除成功: ${userId}`);
return true;
} catch (error) {
const err = error as Error;
this.logger.error('API Key删除失败', {
operation: 'deleteApiKey',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return false;
}
}
/**
* 检查API Key是否存在
*
* @param userId 用户ID
* @returns Promise<boolean> 是否存在
*/
async hasApiKey(userId: string): Promise<boolean> {
try {
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
return await this.redisService.exists(storageKey);
} catch (error) {
this.logger.error('检查API Key存在性失败', {
operation: 'hasApiKey',
userId,
error: (error as Error).message,
});
return false;
}
}
/**
* 记录安全事件
*
* 功能描述:
* 记录安全相关的事件到Redis用于审计和监控
*
* @param event 安全事件
* @returns Promise<void>
*/
async logSecurityEvent(event: SecurityEvent): Promise<void> {
try {
const logKey = `${this.SECURITY_LOG_PREFIX}${event.userId}:${Date.now()}`;
await this.redisService.setex(
logKey,
this.SECURITY_LOG_RETENTION,
JSON.stringify(event)
);
// 根据严重级别记录到应用日志
const logContext = {
operation: 'logSecurityEvent',
eventType: event.eventType,
severity: event.severity,
userId: event.userId,
details: event.details,
ipAddress: event.ipAddress,
timestamp: event.timestamp.toISOString(),
};
switch (event.severity) {
case SecuritySeverity.CRITICAL:
this.logger.error('安全事件 - 严重', logContext);
break;
case SecuritySeverity.WARNING:
this.logger.warn('安全事件 - 警告', logContext);
break;
case SecuritySeverity.INFO:
default:
this.logger.log('安全事件 - 信息', logContext);
break;
}
} catch (error) {
this.logger.error('记录安全事件失败', {
operation: 'logSecurityEvent',
event,
error: (error as Error).message,
});
}
}
/**
* 记录可疑访问
*
* 功能描述:
* 当检测到异常操作时记录可疑访问事件
*
* @param userId 用户ID
* @param reason 可疑原因
* @param details 详细信息
* @param metadata 元数据
* @returns Promise<void>
*/
async logSuspiciousAccess(
userId: string,
reason: string,
details: Record<string, any>,
metadata?: { ipAddress?: string; userAgent?: string }
): Promise<void> {
await this.logSecurityEvent({
eventType: SecurityEventType.SUSPICIOUS_ACCESS,
severity: SecuritySeverity.WARNING,
userId,
details: {
reason,
...details,
},
timestamp: new Date(),
...metadata,
});
}
/**
* 获取用户安全事件历史
*
* @param userId 用户ID
* @param limit 返回数量限制
* @returns Promise<SecurityEvent[]> 安全事件列表
*/
async getSecurityEventHistory(userId: string, limit: number = 100): Promise<SecurityEvent[]> {
// 注意这是一个简化实现实际应该使用Redis的有序集合或扫描功能
// 当前实现仅作为示例
this.logger.debug('获取安全事件历史', {
operation: 'getSecurityEventHistory',
userId,
limit,
});
return [];
}
/**
* 获取API Key统计信息
*
* @param userId 用户ID
* @returns Promise<{exists: boolean, accessCount?: number, lastAccessedAt?: Date, createdAt?: Date}>
*/
async getApiKeyStats(userId: string): Promise<{
exists: boolean;
accessCount?: number;
lastAccessedAt?: Date;
createdAt?: Date;
updatedAt?: Date;
}> {
try {
const storageKey = `${this.API_KEY_PREFIX}${userId}`;
const data = await this.redisService.get(storageKey);
if (!data) {
return { exists: false };
}
const storageData: EncryptedApiKey = JSON.parse(data);
return {
exists: true,
accessCount: storageData.accessCount,
lastAccessedAt: storageData.lastAccessedAt ? new Date(storageData.lastAccessedAt) : undefined,
createdAt: new Date(storageData.createdAt),
updatedAt: new Date(storageData.updatedAt),
};
} catch (error) {
this.logger.error('获取API Key统计信息失败', {
operation: 'getApiKeyStats',
userId,
error: (error as Error).message,
});
return { exists: false };
}
}
// ==================== 私有方法 ====================
/**
* 加密数据
*
* @param plaintext 明文
* @returns 加密结果
* @private
*/
private encrypt(plaintext: string): {
encryptedData: string;
iv: string;
authTag: string;
} {
const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv(
this.ENCRYPTION_ALGORITHM,
this.encryptionKey,
iv
);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
encryptedData: encrypted,
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
};
}
/**
* 解密数据
*
* @param encryptedData 加密数据
* @param ivHex IV十六进制
* @param authTagHex 认证标签(十六进制)
* @returns 解密后的明文
* @private
*/
private decrypt(encryptedData: string, ivHex: string, authTagHex: string): string {
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(
this.ENCRYPTION_ALGORITHM,
this.encryptionKey,
iv
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* 验证API Key格式
*
* @param apiKey API Key
* @returns boolean 是否有效
* @private
*/
private isValidApiKeyFormat(apiKey: string): boolean {
// Zulip API Key通常是32字符的字母数字字符串
// 这里放宽限制以支持不同格式
if (!apiKey || apiKey.length < 16 || apiKey.length > 128) {
return false;
}
// 只允许字母、数字和一些特殊字符
return /^[a-zA-Z0-9_-]+$/.test(apiKey);
}
/**
* 检查访问频率限制
*
* @param userId 用户ID
* @returns Promise<{allowed: boolean, currentCount: number}>
* @private
*/
private async checkAccessRateLimit(userId: string): Promise<{
allowed: boolean;
currentCount: number;
}> {
try {
const rateLimitKey = `${this.ACCESS_COUNT_PREFIX}${userId}`;
const currentCount = await this.redisService.get(rateLimitKey);
const count = currentCount ? parseInt(currentCount, 10) : 0;
if (count >= this.MAX_ACCESS_PER_MINUTE) {
return { allowed: false, currentCount: count };
}
// 增加计数
if (count === 0) {
await this.redisService.setex(rateLimitKey, 60, '1');
} else {
await this.redisService.incr(rateLimitKey);
}
return { allowed: true, currentCount: count + 1 };
} catch (error) {
// 频率检查失败时默认允许
this.logger.warn('访问频率检查失败', {
operation: 'checkAccessRateLimit',
userId,
error: (error as Error).message,
});
return { allowed: true, currentCount: 0 };
}
}
}

View File

@@ -1,598 +0,0 @@
/**
* 配置管理服务测试
*
* 功能描述:
* - 测试ConfigManagerService的核心功能
* - 包含属性测试验证配置验证正确性
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import { ConfigManagerService, MapConfig, ZulipConfig } from './config-manager.service';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
import * as fs from 'fs';
import * as path from 'path';
// Mock fs module
jest.mock('fs');
describe('ConfigManagerService', () => {
let service: ConfigManagerService;
let mockLogger: jest.Mocked<AppLoggerService>;
const mockFs = fs as jest.Mocked<typeof fs>;
// 默认有效配置
const validMapConfig = {
maps: [
{
mapId: 'novice_village',
mapName: '新手村',
zulipStream: 'Novice Village',
interactionObjects: [
{
objectId: 'notice_board',
objectName: '公告板',
zulipTopic: 'Notice Board',
position: { x: 100, y: 150 }
}
]
},
{
mapId: 'tavern',
mapName: '酒馆',
zulipStream: 'Tavern',
interactionObjects: [
{
objectId: 'bar_counter',
objectName: '吧台',
zulipTopic: 'Bar Counter',
position: { x: 150, y: 100 }
}
]
}
]
};
beforeEach(async () => {
jest.clearAllMocks();
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any;
// 默认mock fs行为
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify(validMapConfig));
mockFs.writeFileSync.mockImplementation(() => {});
mockFs.mkdirSync.mockImplementation(() => undefined);
const module: TestingModule = await Test.createTestingModule({
providers: [
ConfigManagerService,
{
provide: AppLoggerService,
useValue: mockLogger,
},
],
}).compile();
service = module.get<ConfigManagerService>(ConfigManagerService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('loadMapConfig - 加载地图配置', () => {
it('应该成功加载有效的地图配置', async () => {
await service.loadMapConfig();
const mapIds = service.getAllMapIds();
expect(mapIds).toContain('novice_village');
expect(mapIds).toContain('tavern');
});
it('应该在配置文件不存在时创建默认配置', async () => {
mockFs.existsSync.mockReturnValue(false);
await service.loadMapConfig();
expect(mockFs.writeFileSync).toHaveBeenCalled();
});
it('应该在配置格式无效时抛出错误', async () => {
mockFs.readFileSync.mockReturnValue(JSON.stringify({ invalid: 'config' }));
await expect(service.loadMapConfig()).rejects.toThrow('配置格式无效');
});
});
describe('getStreamByMap - 根据地图获取Stream', () => {
it('应该返回正确的Stream名称', () => {
const stream = service.getStreamByMap('novice_village');
expect(stream).toBe('Novice Village');
});
it('应该在地图不存在时返回null', () => {
const stream = service.getStreamByMap('nonexistent');
expect(stream).toBeNull();
});
it('应该在mapId为空时返回null', () => {
const stream = service.getStreamByMap('');
expect(stream).toBeNull();
});
});
describe('getMapConfig - 获取地图配置', () => {
it('应该返回完整的地图配置', () => {
const config = service.getMapConfig('novice_village');
expect(config).toBeDefined();
expect(config?.mapId).toBe('novice_village');
expect(config?.mapName).toBe('新手村');
expect(config?.zulipStream).toBe('Novice Village');
});
it('应该在地图不存在时返回null', () => {
const config = service.getMapConfig('nonexistent');
expect(config).toBeNull();
});
});
describe('getTopicByObject - 根据交互对象获取Topic', () => {
it('应该返回正确的Topic名称', () => {
const topic = service.getTopicByObject('novice_village', 'notice_board');
expect(topic).toBe('Notice Board');
});
it('应该在对象不存在时返回null', () => {
const topic = service.getTopicByObject('novice_village', 'nonexistent');
expect(topic).toBeNull();
});
it('应该在地图不存在时返回null', () => {
const topic = service.getTopicByObject('nonexistent', 'notice_board');
expect(topic).toBeNull();
});
});
describe('findNearbyObject - 查找附近的交互对象', () => {
it('应该找到半径内的交互对象', () => {
const obj = service.findNearbyObject('novice_village', 110, 160, 50);
expect(obj).toBeDefined();
expect(obj?.objectId).toBe('notice_board');
});
it('应该在没有附近对象时返回null', () => {
const obj = service.findNearbyObject('novice_village', 500, 500, 50);
expect(obj).toBeNull();
});
});
describe('getMapIdByStream - 根据Stream获取地图ID', () => {
it('应该返回正确的地图ID', () => {
const mapId = service.getMapIdByStream('Novice Village');
expect(mapId).toBe('novice_village');
});
it('应该支持大小写不敏感查询', () => {
const mapId = service.getMapIdByStream('novice village');
expect(mapId).toBe('novice_village');
});
it('应该在Stream不存在时返回null', () => {
const mapId = service.getMapIdByStream('nonexistent');
expect(mapId).toBeNull();
});
});
describe('validateConfig - 验证配置', () => {
it('应该对有效配置返回valid=true除了API Key警告', async () => {
const result = await service.validateConfig();
// 由于测试环境没有设置API Key会有一个错误
// 但地图配置应该是有效的
expect(result.errors.some(e => e.includes('地图配置无效'))).toBe(false);
});
});
describe('validateMapConfigDetailed - 详细验证地图配置', () => {
it('应该对有效配置返回valid=true', () => {
const config = {
mapId: 'test_map',
mapName: '测试地图',
zulipStream: 'Test Stream',
interactionObjects: [] as any[]
};
const result = service.validateMapConfigDetailed(config);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('应该检测缺少mapId的错误', () => {
const config = {
mapName: '测试地图',
zulipStream: 'Test Stream',
interactionObjects: [] as any[]
};
const result = service.validateMapConfigDetailed(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain('缺少mapId字段');
});
it('应该检测缺少mapName的错误', () => {
const config = {
mapId: 'test_map',
zulipStream: 'Test Stream',
interactionObjects: [] as any[]
};
const result = service.validateMapConfigDetailed(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain('缺少mapName字段');
});
it('应该检测缺少zulipStream的错误', () => {
const config = {
mapId: 'test_map',
mapName: '测试地图',
interactionObjects: [] as any[]
};
const result = service.validateMapConfigDetailed(config);
expect(result.valid).toBe(false);
expect(result.errors).toContain('缺少zulipStream字段');
});
it('应该检测交互对象中缺少字段的错误', () => {
const config = {
mapId: 'test_map',
mapName: '测试地图',
zulipStream: 'Test Stream',
interactionObjects: [
{
objectId: 'test_obj',
// 缺少objectName, zulipTopic, position
}
]
};
const result = service.validateMapConfigDetailed(config);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('getConfigStats - 获取配置统计', () => {
it('应该返回正确的统计信息', () => {
const stats = service.getConfigStats();
expect(stats.mapCount).toBe(2);
expect(stats.totalObjects).toBe(2);
expect(stats.isValid).toBe(true);
});
});
/**
* 属性测试: 配置验证
*
* **Feature: zulip-integration, Property 12: 配置验证**
* **Validates: Requirements 10.5**
*
* 对于任何系统配置,系统应该在启动时验证配置的有效性,
* 并在发现无效配置时报告详细的错误信息
*/
describe('Property 12: 配置验证', () => {
/**
* 属性: 对于任何有效的地图配置验证应该返回valid=true
* 验证需求 10.5: 验证配置时系统应在启动时检查配置的有效性并报告错误
*/
it('对于任何有效的地图配置验证应该返回valid=true', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的mapId
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的mapName
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
// 生成有效的zulipStream
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
// 生成有效的交互对象数组
fc.array(
fc.record({
objectId: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
objectName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
zulipTopic: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
position: fc.record({
x: fc.integer({ min: 0, max: 10000 }),
y: fc.integer({ min: 0, max: 10000 }),
}),
}),
{ minLength: 0, maxLength: 10 }
),
async (mapId, mapName, zulipStream, interactionObjects) => {
const config = {
mapId: mapId.trim(),
mapName: mapName.trim(),
zulipStream: zulipStream.trim(),
interactionObjects: interactionObjects.map(obj => ({
objectId: obj.objectId.trim(),
objectName: obj.objectName.trim(),
zulipTopic: obj.zulipTopic.trim(),
position: obj.position,
})),
};
const result = service.validateMapConfigDetailed(config);
// 有效配置应该通过验证
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何缺少必填字段的配置验证应该返回valid=false并包含错误信息
* 验证需求 10.5: 验证配置时系统应在启动时检查配置的有效性并报告错误
*/
it('对于任何缺少mapId的配置验证应该返回valid=false', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的mapName
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
// 生成有效的zulipStream
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
async (mapName, zulipStream) => {
const config = {
// 缺少mapId
mapName: mapName.trim(),
zulipStream: zulipStream.trim(),
interactionObjects: [] as any[],
};
const result = service.validateMapConfigDetailed(config);
// 缺少mapId应该验证失败
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('mapId'))).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何缺少mapName的配置验证应该返回valid=false
*/
it('对于任何缺少mapName的配置验证应该返回valid=false', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的mapId
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的zulipStream
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
async (mapId, zulipStream) => {
const config = {
mapId: mapId.trim(),
// 缺少mapName
zulipStream: zulipStream.trim(),
interactionObjects: [] as any[],
};
const result = service.validateMapConfigDetailed(config);
// 缺少mapName应该验证失败
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('mapName'))).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何缺少zulipStream的配置验证应该返回valid=false
*/
it('对于任何缺少zulipStream的配置验证应该返回valid=false', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的mapId
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的mapName
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
async (mapId, mapName) => {
const config = {
mapId: mapId.trim(),
mapName: mapName.trim(),
// 缺少zulipStream
interactionObjects: [] as any[],
};
const result = service.validateMapConfigDetailed(config);
// 缺少zulipStream应该验证失败
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('zulipStream'))).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何交互对象缺少必填字段的配置验证应该返回valid=false
*/
it('对于任何交互对象缺少objectId的配置验证应该返回valid=false', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的地图配置
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
// 生成有效的交互对象但缺少objectId
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.integer({ min: 0, max: 10000 }),
fc.integer({ min: 0, max: 10000 }),
async (mapId, mapName, zulipStream, objectName, zulipTopic, x, y) => {
const config = {
mapId: mapId.trim(),
mapName: mapName.trim(),
zulipStream: zulipStream.trim(),
interactionObjects: [
{
// 缺少objectId
objectName: objectName.trim(),
zulipTopic: zulipTopic.trim(),
position: { x, y },
}
],
};
const result = service.validateMapConfigDetailed(config);
// 缺少objectId应该验证失败
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('objectId'))).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何交互对象position无效的配置验证应该返回valid=false
*/
it('对于任何交互对象position无效的配置验证应该返回valid=false', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的地图配置
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
// 生成有效的交互对象字段
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
async (mapId, mapName, zulipStream, objectId, objectName, zulipTopic) => {
const config = {
mapId: mapId.trim(),
mapName: mapName.trim(),
zulipStream: zulipStream.trim(),
interactionObjects: [
{
objectId: objectId.trim(),
objectName: objectName.trim(),
zulipTopic: zulipTopic.trim(),
// 缺少position
}
],
};
const result = service.validateMapConfigDetailed(config);
// 缺少position应该验证失败
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('position'))).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 验证结果的错误数量应该与实际错误数量一致
*/
it('验证结果的错误数量应该与实际错误数量一致', async () => {
await fc.assert(
fc.asyncProperty(
// 随机决定是否包含各个字段
fc.boolean(),
fc.boolean(),
fc.boolean(),
// 生成字段值
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
async (includeMapId, includeMapName, includeZulipStream, mapId, mapName, zulipStream) => {
const config: any = {
interactionObjects: [] as any[],
};
let expectedErrors = 0;
if (includeMapId) {
config.mapId = mapId.trim();
} else {
expectedErrors++;
}
if (includeMapName) {
config.mapName = mapName.trim();
} else {
expectedErrors++;
}
if (includeZulipStream) {
config.zulipStream = zulipStream.trim();
} else {
expectedErrors++;
}
const result = service.validateMapConfigDetailed(config);
// 错误数量应该与预期一致
expect(result.errors.length).toBe(expectedErrors);
expect(result.valid).toBe(expectedErrors === 0);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何空字符串字段验证应该返回valid=false
*/
it('对于任何空字符串字段验证应该返回valid=false', async () => {
await fc.assert(
fc.asyncProperty(
// 随机选择哪个字段为空
fc.constantFrom('mapId', 'mapName', 'zulipStream'),
// 生成有效的字段值
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
async (emptyField, mapId, mapName, zulipStream) => {
const config: any = {
mapId: emptyField === 'mapId' ? '' : mapId.trim(),
mapName: emptyField === 'mapName' ? '' : mapName.trim(),
zulipStream: emptyField === 'zulipStream' ? '' : zulipStream.trim(),
interactionObjects: [] as any[],
};
const result = service.validateMapConfigDetailed(config);
// 空字符串字段应该验证失败
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes(emptyField))).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,573 +0,0 @@
/**
* 错误处理服务测试
*
* 功能描述:
* - 测试ErrorHandlerService的核心功能
* - 包含属性测试验证错误处理和服务降级
*
* **Feature: zulip-integration, Property 9: 错误处理和服务降级**
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as fc from 'fast-check';
import {
ErrorHandlerService,
ErrorType,
ServiceStatus,
LoadStatus,
ErrorHandlingResult,
RetryConfig,
} from './error-handler.service';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
describe('ErrorHandlerService', () => {
let service: ErrorHandlerService;
let mockLogger: jest.Mocked<AppLoggerService>;
let mockConfigService: jest.Mocked<ConfigService>;
beforeEach(async () => {
jest.clearAllMocks();
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any;
mockConfigService = {
get: jest.fn((key: string, defaultValue?: any) => {
const config: Record<string, any> = {
'ZULIP_DEGRADED_MODE_ENABLED': 'true',
'ZULIP_AUTO_RECONNECT_ENABLED': 'true',
'ZULIP_MAX_RECONNECT_ATTEMPTS': 5,
'ZULIP_RECONNECT_BASE_DELAY': 1000,
'ZULIP_API_TIMEOUT': 30000,
'ZULIP_MAX_RETRIES': 3,
'ZULIP_MAX_CONNECTIONS': 1000,
};
return config[key] ?? defaultValue;
}),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
ErrorHandlerService,
{
provide: AppLoggerService,
useValue: mockLogger,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
service = module.get<ErrorHandlerService>(ErrorHandlerService);
});
afterEach(async () => {
// 清理服务资源
await service.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('基础功能测试', () => {
it('应该正确初始化服务状态', () => {
expect(service.getServiceStatus()).toBe(ServiceStatus.NORMAL);
expect(service.getLoadStatus()).toBe(LoadStatus.NORMAL);
expect(service.isServiceAvailable()).toBe(true);
expect(service.isDegradedMode()).toBe(false);
});
it('应该正确读取配置', () => {
const config = service.getConfig();
expect(config.degradedModeEnabled).toBe(true);
expect(config.autoReconnectEnabled).toBe(true);
expect(config.maxReconnectAttempts).toBe(5);
expect(config.apiTimeout).toBe(30000);
expect(config.maxRetries).toBe(3);
});
});
describe('错误分类测试', () => {
it('应该正确分类认证错误', async () => {
const error = { code: 401, message: 'Unauthorized' };
const result = await service.handleZulipError(error, 'testOperation');
expect(result.shouldRetry).toBe(false);
expect(result.message).toContain('认证');
});
it('应该正确分类频率限制错误', async () => {
const error = { code: 429, message: 'Too many requests' };
const result = await service.handleZulipError(error, 'testOperation');
expect(result.shouldRetry).toBe(true);
expect(result.retryAfter).toBe(60000);
});
it('应该正确分类连接错误', async () => {
const error = { code: 'ECONNREFUSED', message: 'Connection refused' };
const result = await service.handleZulipError(error, 'testOperation');
expect(result.shouldRetry).toBe(true);
});
it('应该正确分类超时错误', async () => {
const error = { code: 'ETIMEDOUT', message: 'Request timeout' };
const result = await service.handleZulipError(error, 'testOperation');
expect(result.shouldRetry).toBe(true);
});
});
describe('降级模式测试', () => {
it('应该正确启用降级模式', async () => {
await service.enableDegradedMode();
expect(service.isDegradedMode()).toBe(true);
expect(service.getServiceStatus()).toBe(ServiceStatus.DEGRADED);
});
it('应该正确恢复正常模式', async () => {
await service.enableDegradedMode();
await service.enableNormalMode();
expect(service.isDegradedMode()).toBe(false);
expect(service.getServiceStatus()).toBe(ServiceStatus.NORMAL);
});
it('降级模式禁用时不应该启用', async () => {
// 重新创建服务,禁用降级模式
mockConfigService.get.mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ZULIP_DEGRADED_MODE_ENABLED') return 'false';
return defaultValue;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
ErrorHandlerService,
{ provide: AppLoggerService, useValue: mockLogger },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
const disabledService = module.get<ErrorHandlerService>(ErrorHandlerService);
await disabledService.enableDegradedMode();
expect(disabledService.isDegradedMode()).toBe(false);
await disabledService.onModuleDestroy();
});
});
describe('重试机制测试', () => {
it('应该在操作成功时返回结果', async () => {
const operation = jest.fn().mockResolvedValue('success');
const result = await service.retryWithBackoff(operation);
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(1);
});
it('应该在失败后重试', async () => {
const operation = jest.fn()
.mockRejectedValueOnce(new Error('First failure'))
.mockResolvedValue('success');
const result = await service.retryWithBackoff(operation, { maxRetries: 3, baseDelay: 10 });
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(2);
});
it('应该在达到最大重试次数后抛出错误', async () => {
const operation = jest.fn().mockRejectedValue(new Error('Always fails'));
await expect(
service.retryWithBackoff(operation, { maxRetries: 2, baseDelay: 10 })
).rejects.toThrow('Always fails');
expect(operation).toHaveBeenCalledTimes(3); // 初始 + 2次重试
});
});
describe('超时执行测试', () => {
it('应该在操作完成前返回结果', async () => {
const operation = jest.fn().mockResolvedValue('success');
const result = await service.executeWithTimeout(operation, {
timeout: 5000,
operation: 'testOperation',
});
expect(result).toBe('success');
});
it('应该在超时时抛出错误', async () => {
const operation = jest.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve('late'), 1000))
);
await expect(
service.executeWithTimeout(operation, {
timeout: 50,
operation: 'testOperation',
})
).rejects.toThrow('操作超时');
});
});
describe('连接管理测试', () => {
it('应该正确更新活跃连接数', () => {
service.updateActiveConnections(10);
expect(service.getLoadStatus()).toBe(LoadStatus.NORMAL);
service.updateActiveConnections(690); // 总共70070%
expect(service.getLoadStatus()).toBe(LoadStatus.HIGH);
service.updateActiveConnections(200); // 总共90090%
expect(service.getLoadStatus()).toBe(LoadStatus.CRITICAL);
});
it('应该在负载过高时限制新连接', () => {
service.updateActiveConnections(950);
expect(service.shouldLimitNewConnections()).toBe(true);
});
it('应该在负载正常时允许新连接', () => {
service.updateActiveConnections(100);
expect(service.shouldLimitNewConnections()).toBe(false);
});
});
describe('自动重连测试', () => {
it('应该成功调度重连', async () => {
const reconnectCallback = jest.fn().mockResolvedValue(true);
const scheduled = await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 3,
baseDelay: 10,
});
expect(scheduled).toBe(true);
// 等待重连完成
await new Promise(resolve => setTimeout(resolve, 100));
expect(reconnectCallback).toHaveBeenCalled();
});
it('应该在重连成功后清理状态', async () => {
const reconnectCallback = jest.fn().mockResolvedValue(true);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 3,
baseDelay: 10,
});
// 等待重连完成
await new Promise(resolve => setTimeout(resolve, 100));
expect(service.getReconnectState('user1')).toBeNull();
});
it('应该能够取消重连', async () => {
const reconnectCallback = jest.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(false), 500))
);
await service.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 5,
baseDelay: 100,
});
// 立即取消
service.cancelReconnect('user1');
expect(service.getReconnectState('user1')).toBeNull();
});
it('自动重连禁用时不应该调度', async () => {
// 重新创建服务,禁用自动重连
mockConfigService.get.mockImplementation((key: string, defaultValue?: any) => {
if (key === 'ZULIP_AUTO_RECONNECT_ENABLED') return 'false';
return defaultValue;
});
const module: TestingModule = await Test.createTestingModule({
providers: [
ErrorHandlerService,
{ provide: AppLoggerService, useValue: mockLogger },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
const disabledService = module.get<ErrorHandlerService>(ErrorHandlerService);
const reconnectCallback = jest.fn().mockResolvedValue(true);
const scheduled = await disabledService.scheduleReconnect({
userId: 'user1',
reconnectCallback,
maxAttempts: 3,
baseDelay: 10,
});
expect(scheduled).toBe(false);
expect(reconnectCallback).not.toHaveBeenCalled();
await disabledService.onModuleDestroy();
});
});
/**
* 属性测试: 错误处理和服务降级
*
* **Feature: zulip-integration, Property 9: 错误处理和服务降级**
* **Validates: Requirements 8.1, 8.2, 8.3, 8.4**
*
* 对于任何Zulip服务不可用、API超时或连接断开的情况
* 系统应该实施适当的错误处理、重试机制或服务降级策略
*/
describe('Property 9: 错误处理和服务降级', () => {
/**
* 属性: 对于任何错误,系统应该返回有效的处理结果
*/
it('对于任何错误,系统应该返回有效的处理结果', async () => {
await fc.assert(
fc.asyncProperty(
// 生成错误码
fc.oneof(
fc.constant(401),
fc.constant(429),
fc.constant(500),
fc.constant('ECONNREFUSED'),
fc.constant('ETIMEDOUT'),
fc.integer({ min: 400, max: 599 })
),
// 生成错误消息
fc.string({ minLength: 1, maxLength: 200 }),
// 生成操作名称
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
async (errorCode, errorMessage, operation) => {
const error = { code: errorCode, message: errorMessage };
const result = await service.handleZulipError(error, operation);
// 验证结果结构
expect(result).toBeDefined();
expect(typeof result.success).toBe('boolean');
expect(typeof result.shouldRetry).toBe('boolean');
expect(typeof result.message).toBe('string');
expect(result.message.length).toBeGreaterThan(0);
// 如果需要重试,应该有重试延迟
if (result.shouldRetry && result.retryAfter !== undefined) {
expect(result.retryAfter).toBeGreaterThan(0);
}
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 认证错误不应该重试
*/
it('认证错误不应该重试', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 200 }),
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
async (errorMessage, operation) => {
const error = { code: 401, message: errorMessage };
const result = await service.handleZulipError(error, operation);
// 认证错误不应该重试
expect(result.shouldRetry).toBe(false);
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 连接错误应该触发重试
*/
it('连接错误应该触发重试', async () => {
await fc.assert(
fc.asyncProperty(
fc.oneof(
fc.constant('ECONNREFUSED'),
fc.constant('ENOTFOUND'),
fc.constant('connection refused'),
fc.constant('network error')
),
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
async (errorCode, operation) => {
const error = { code: errorCode, message: `Connection error: ${errorCode}` };
const result = await service.handleConnectionError(error, operation);
// 连接错误应该重试
expect(result.shouldRetry).toBe(true);
expect(result.retryAfter).toBeGreaterThan(0);
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 重试机制应该使用指数退避
*/
it('重试机制应该使用指数退避', async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 2 }), // 减少重试次数
fc.integer({ min: 10, max: 50 }), // 使用更小的延迟
async (maxRetries, baseDelay) => {
let attemptCount = 0;
const operation = jest.fn().mockImplementation(() => {
attemptCount++;
return Promise.reject(new Error('Test error'));
});
try {
await service.retryWithBackoff(operation, {
maxRetries,
baseDelay,
backoffMultiplier: 2,
maxDelay: 1000,
});
} catch {
// 预期会失败
}
// 验证重试次数正确
expect(attemptCount).toBe(maxRetries + 1);
}
),
{ numRuns: 10 } // 减少运行次数
);
}, 30000);
/**
* 属性: 超时操作应该在指定时间内返回或抛出错误
*/
it('超时操作应该在指定时间内返回或抛出错误', async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 50, max: 200 }),
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
async (timeout, operationName) => {
const startTime = Date.now();
// 创建一个会超时的操作
const slowOperation = () => new Promise(resolve =>
setTimeout(() => resolve('late'), timeout + 100)
);
try {
await service.executeWithTimeout(slowOperation, {
timeout,
operation: operationName,
});
// 如果没有超时,说明操作完成了
} catch (error) {
const elapsed = Date.now() - startTime;
// 应该在超时时间附近抛出错误
expect(elapsed).toBeLessThan(timeout + 100);
expect((error as Error).message).toContain('超时');
}
}
),
{ numRuns: 20 } // 减少运行次数因为涉及实际延迟
);
}, 30000);
/**
* 属性: 负载状态应该根据连接数正确更新
*/
it('负载状态应该根据连接数正确更新', async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: 2000 }),
async (connectionCount) => {
// 重置连接数
service.updateActiveConnections(-service['activeConnections']);
service.updateActiveConnections(connectionCount);
const maxConnections = service.getConfig().maxConnections;
const ratio = connectionCount / maxConnections;
const loadStatus = service.getLoadStatus();
if (ratio >= 0.9) {
expect(loadStatus).toBe(LoadStatus.CRITICAL);
} else if (ratio >= 0.7) {
expect(loadStatus).toBe(LoadStatus.HIGH);
} else {
expect(loadStatus).toBe(LoadStatus.NORMAL);
}
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 服务健康检查应该返回完整的状态信息
*/
it('服务健康检查应该返回完整的状态信息', async () => {
await fc.assert(
fc.asyncProperty(
fc.boolean(),
async (shouldDegrade) => {
if (shouldDegrade) {
await service.enableDegradedMode();
} else {
await service.enableNormalMode();
}
const health = await service.checkServiceHealth();
// 验证健康检查结果结构
expect(health).toBeDefined();
expect(health.status).toBeDefined();
expect(health.details).toBeDefined();
expect(health.details.serviceStatus).toBeDefined();
expect(health.details.errorCounts).toBeDefined();
// 验证状态一致性
if (shouldDegrade && service.isDegradedModeEnabled()) {
expect(health.status).toBe(ServiceStatus.DEGRADED);
}
}
),
{ numRuns: 50 }
);
}, 30000);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import { MessageFilterService, ViolationType, ContentFilterResult } from './message-filter.service';
import { ConfigManagerService } from './config-manager.service';
import { MessageFilterService, ViolationType } from './message_filter.service';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
import { IRedisService } from '../../../core/redis/redis.interface';
@@ -21,7 +21,7 @@ describe('MessageFilterService', () => {
let service: MessageFilterService;
let mockLogger: jest.Mocked<AppLoggerService>;
let mockRedisService: jest.Mocked<IRedisService>;
let mockConfigManager: jest.Mocked<ConfigManagerService>;
let mockConfigManager: jest.Mocked<IZulipConfigService>;
// 内存存储模拟Redis
let memoryStore: Map<string, { value: string; expireAt?: number }>;
@@ -100,6 +100,14 @@ describe('MessageFilterService', () => {
hasMap: jest.fn().mockImplementation((mapId: string) => {
return ['novice_village', 'tavern', 'market'].includes(mapId);
}),
getMapIdByStream: jest.fn(),
getTopicByObject: jest.fn(),
getZulipConfig: jest.fn(),
hasStream: jest.fn(),
getAllMapIds: jest.fn(),
getAllStreams: jest.fn(),
reloadConfig: jest.fn(),
validateConfig: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
@@ -114,7 +122,7 @@ describe('MessageFilterService', () => {
useValue: mockRedisService,
},
{
provide: ConfigManagerService,
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigManager,
},
],

View File

@@ -30,7 +30,7 @@
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { IRedisService } from '../../../core/redis/redis.interface';
import { ConfigManagerService } from './config-manager.service';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
/**
*
@@ -90,6 +90,28 @@ export interface SensitiveWordConfig {
category?: string;
}
/**
*
*
*
* -
* -
* -
* - ConfigManager集成实现位置权限验证
*
*
* - filterContent():
* - checkRateLimit():
* - validatePermission():
* - validateMessage():
* - logViolation():
*
* 使
* -
* -
* -
* -
*/
@Injectable()
export class MessageFilterService {
private readonly RATE_LIMIT_PREFIX = 'zulip:rate_limit:';
@@ -127,8 +149,8 @@ export class MessageFilterService {
constructor(
@Inject('REDIS_SERVICE')
private readonly redisService: IRedisService,
@Inject(forwardRef(() => ConfigManagerService))
private readonly configManager: ConfigManagerService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
) {
this.logger.log('MessageFilterService初始化完成');
}

View File

@@ -1,733 +0,0 @@
/**
* 监控服务测试
*
* 功能描述:
* - 测试MonitoringService的核心功能
* - 包含属性测试验证操作确认和日志记录
* - 包含属性测试验证系统监控和告警
*
* **Feature: zulip-integration, Property 10: 操作确认和日志记录**
* **Validates: Requirements 4.5, 8.5, 9.1, 9.2, 9.3**
*
* **Feature: zulip-integration, Property 11: 系统监控和告警**
* **Validates: Requirements 9.4**
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
import * as fc from 'fast-check';
import {
MonitoringService,
ConnectionEventType,
ApiCallResult,
AlertLevel,
ConnectionLog,
ApiCallLog,
MessageForwardLog,
OperationConfirmation,
} from './monitoring.service';
describe('MonitoringService', () => {
let service: MonitoringService;
let mockConfigService: jest.Mocked<ConfigService>;
beforeEach(async () => {
jest.clearAllMocks();
// Mock NestJS Logger
jest.spyOn(Logger.prototype, 'log').mockImplementation();
jest.spyOn(Logger.prototype, 'error').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
mockConfigService = {
get: jest.fn((key: string, defaultValue?: any) => {
const config: Record<string, any> = {
'MONITORING_HEALTH_CHECK_INTERVAL': 60000,
'MONITORING_ERROR_RATE_THRESHOLD': 0.1,
'MONITORING_RESPONSE_TIME_THRESHOLD': 5000,
'MONITORING_MEMORY_THRESHOLD': 0.9,
};
return config[key] ?? defaultValue;
}),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
MonitoringService,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
service = module.get<MonitoringService>(MonitoringService);
});
afterEach(async () => {
service.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('基础功能测试', () => {
it('应该正确初始化统计数据', () => {
const stats = service.getStats();
expect(stats.connections.total).toBe(0);
expect(stats.apiCalls.total).toBe(0);
expect(stats.messages.upstream).toBe(0);
expect(stats.alerts.total).toBe(0);
});
it('应该正确重置统计数据', () => {
// 添加一些数据
service.logConnection({
socketId: 'socket1',
eventType: ConnectionEventType.CONNECTED,
timestamp: new Date(),
});
// 重置
service.resetStats();
const stats = service.getStats();
expect(stats.connections.total).toBe(0);
});
});
describe('连接日志测试', () => {
it('应该正确记录连接事件', () => {
service.logConnection({
socketId: 'socket1',
userId: 'user1',
eventType: ConnectionEventType.CONNECTED,
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.connections.total).toBe(1);
expect(stats.connections.active).toBe(1);
});
it('应该正确记录断开事件', () => {
service.logConnection({
socketId: 'socket1',
eventType: ConnectionEventType.CONNECTED,
timestamp: new Date(),
});
service.logConnection({
socketId: 'socket1',
eventType: ConnectionEventType.DISCONNECTED,
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.connections.active).toBe(0);
});
it('应该正确记录错误事件', () => {
service.logConnection({
socketId: 'socket1',
eventType: ConnectionEventType.ERROR,
error: 'Connection failed',
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.connections.errors).toBe(1);
});
});
describe('API调用日志测试', () => {
it('应该正确记录成功的API调用', () => {
service.logApiCall({
operation: 'sendMessage',
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 100,
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.apiCalls.total).toBe(1);
expect(stats.apiCalls.success).toBe(1);
});
it('应该正确记录失败的API调用', () => {
service.logApiCall({
operation: 'sendMessage',
userId: 'user1',
result: ApiCallResult.FAILURE,
responseTime: 100,
error: 'API error',
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.apiCalls.failures).toBe(1);
});
it('应该在响应时间过长时发送告警', () => {
const alertHandler = jest.fn();
service.on('alert', alertHandler);
service.logApiCall({
operation: 'sendMessage',
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 10000, // 超过阈值
timestamp: new Date(),
});
expect(alertHandler).toHaveBeenCalled();
});
});
describe('消息转发日志测试', () => {
it('应该正确记录上行消息', () => {
service.logMessageForward({
fromUserId: 'user1',
toUserIds: ['user2'],
stream: 'test-stream',
topic: 'test-topic',
direction: 'upstream',
success: true,
latency: 50,
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.messages.upstream).toBe(1);
});
it('应该正确记录下行消息', () => {
service.logMessageForward({
fromUserId: 'user1',
toUserIds: ['user2', 'user3'],
stream: 'test-stream',
topic: 'test-topic',
direction: 'downstream',
success: true,
latency: 30,
timestamp: new Date(),
});
const stats = service.getStats();
expect(stats.messages.downstream).toBe(1);
});
});
describe('操作确认测试', () => {
it('应该正确记录操作确认', () => {
const confirmHandler = jest.fn();
service.on('operation_confirmed', confirmHandler);
service.confirmOperation({
operationId: 'op1',
operation: 'sendMessage',
userId: 'user1',
success: true,
timestamp: new Date(),
});
expect(confirmHandler).toHaveBeenCalled();
expect(Logger.prototype.log).toHaveBeenCalled();
});
});
describe('告警测试', () => {
it('应该正确发送告警', () => {
const alertHandler = jest.fn();
service.on('alert', alertHandler);
service.sendAlert({
id: 'alert1',
level: AlertLevel.WARNING,
title: 'Test Alert',
message: 'This is a test alert',
component: 'test',
timestamp: new Date(),
});
expect(alertHandler).toHaveBeenCalled();
const stats = service.getStats();
expect(stats.alerts.byLevel[AlertLevel.WARNING]).toBe(1);
});
it('应该正确获取最近的告警', () => {
service.sendAlert({
id: 'alert1',
level: AlertLevel.INFO,
title: 'Alert 1',
message: 'Message 1',
component: 'test',
timestamp: new Date(),
});
service.sendAlert({
id: 'alert2',
level: AlertLevel.WARNING,
title: 'Alert 2',
message: 'Message 2',
component: 'test',
timestamp: new Date(),
});
const recentAlerts = service.getRecentAlerts(10);
expect(recentAlerts.length).toBe(2);
});
});
describe('健康检查测试', () => {
it('应该返回健康状态', async () => {
const health = await service.checkSystemHealth();
expect(health).toBeDefined();
expect(health.status).toBeDefined();
expect(health.components).toBeDefined();
expect(health.timestamp).toBeInstanceOf(Date);
});
});
/**
* 属性测试: 操作确认和日志记录
*
* **Feature: zulip-integration, Property 10: 操作确认和日志记录**
* **Validates: Requirements 4.5, 8.5, 9.1, 9.2, 9.3**
*
* 对于任何重要的系统操作连接管理、API调用、消息转发
* 系统应该记录相应的日志信息,并向客户端返回操作确认
*/
describe('Property 10: 操作确认和日志记录', () => {
beforeEach(() => {
service.resetStats();
});
/**
* 属性: 对于任何连接事件,系统应该正确记录并更新统计
*/
it('对于任何连接事件,系统应该正确记录并更新统计', async () => {
await fc.assert(
fc.asyncProperty(
// 生成Socket ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成用户ID可选
fc.option(fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0)),
// 生成事件类型
fc.constantFrom(
ConnectionEventType.CONNECTED,
ConnectionEventType.DISCONNECTED,
ConnectionEventType.ERROR,
ConnectionEventType.TIMEOUT
),
async (socketId, userId, eventType) => {
const initialStats = service.getStats();
const initialTotal = initialStats.connections.total;
const initialActive = initialStats.connections.active;
const initialErrors = initialStats.connections.errors;
const log: ConnectionLog = {
socketId,
userId: userId ?? undefined,
eventType,
timestamp: new Date(),
};
service.logConnection(log);
const newStats = service.getStats();
// 验证统计更新正确
if (eventType === ConnectionEventType.CONNECTED) {
expect(newStats.connections.total).toBe(initialTotal + 1);
expect(newStats.connections.active).toBe(initialActive + 1);
} else if (eventType === ConnectionEventType.DISCONNECTED) {
expect(newStats.connections.active).toBe(Math.max(0, initialActive - 1));
} else if (eventType === ConnectionEventType.ERROR || eventType === ConnectionEventType.TIMEOUT) {
expect(newStats.connections.errors).toBe(initialErrors + 1);
}
// 验证日志被调用
expect(Logger.prototype.log).toHaveBeenCalled();
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 对于任何API调用系统应该正确记录响应时间和结果
*/
it('对于任何API调用系统应该正确记录响应时间和结果', async () => {
await fc.assert(
fc.asyncProperty(
// 生成操作名称
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成结果
fc.constantFrom(
ApiCallResult.SUCCESS,
ApiCallResult.FAILURE,
ApiCallResult.TIMEOUT,
ApiCallResult.RATE_LIMITED
),
// 生成响应时间
fc.integer({ min: 1, max: 10000 }),
async (operation, userId, result, responseTime) => {
const initialStats = service.getStats();
const initialTotal = initialStats.apiCalls.total;
const initialSuccess = initialStats.apiCalls.success;
const initialFailures = initialStats.apiCalls.failures;
const log: ApiCallLog = {
operation,
userId,
result,
responseTime,
timestamp: new Date(),
};
service.logApiCall(log);
const newStats = service.getStats();
// 验证总数增加
expect(newStats.apiCalls.total).toBe(initialTotal + 1);
// 验证成功/失败计数
if (result === ApiCallResult.SUCCESS) {
expect(newStats.apiCalls.success).toBe(initialSuccess + 1);
} else {
expect(newStats.apiCalls.failures).toBe(initialFailures + 1);
}
// 验证日志被调用
expect(Logger.prototype.log).toHaveBeenCalled();
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 对于任何消息转发,系统应该正确记录方向和延迟
*/
it('对于任何消息转发,系统应该正确记录方向和延迟', async () => {
await fc.assert(
fc.asyncProperty(
// 生成发送者ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成接收者ID列表
fc.array(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
{ minLength: 1, maxLength: 10 }
),
// 生成Stream名称
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成Topic名称
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成方向
fc.constantFrom('upstream' as const, 'downstream' as const),
// 生成延迟
fc.integer({ min: 1, max: 5000 }),
async (fromUserId, toUserIds, stream, topic, direction, latency) => {
const initialStats = service.getStats();
const initialUpstream = initialStats.messages.upstream;
const initialDownstream = initialStats.messages.downstream;
const log: MessageForwardLog = {
fromUserId,
toUserIds,
stream,
topic,
direction,
success: true,
latency,
timestamp: new Date(),
};
service.logMessageForward(log);
const newStats = service.getStats();
// 验证方向计数
if (direction === 'upstream') {
expect(newStats.messages.upstream).toBe(initialUpstream + 1);
} else {
expect(newStats.messages.downstream).toBe(initialDownstream + 1);
}
// 验证日志被调用
expect(Logger.prototype.log).toHaveBeenCalled();
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 对于任何操作确认,系统应该记录并发出事件
*/
it('对于任何操作确认,系统应该记录并发出事件', async () => {
await fc.assert(
fc.asyncProperty(
// 生成操作ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成操作名称
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成成功状态
fc.boolean(),
async (operationId, operation, userId, success) => {
const eventHandler = jest.fn();
service.on('operation_confirmed', eventHandler);
const confirmation: OperationConfirmation = {
operationId,
operation,
userId,
success,
timestamp: new Date(),
};
service.confirmOperation(confirmation);
// 验证事件被发出
expect(eventHandler).toHaveBeenCalledWith(confirmation);
// 验证日志被调用
expect(Logger.prototype.log).toHaveBeenCalled();
service.removeListener('operation_confirmed', eventHandler);
}
),
{ numRuns: 100 }
);
}, 30000);
});
/**
* 属性测试: 系统监控和告警
*
* **Feature: zulip-integration, Property 11: 系统监控和告警**
* **Validates: Requirements 9.4**
*
* 对于任何系统资源异常或性能问题,系统应该检测异常情况并发送相应的告警通知
*/
describe('Property 11: 系统监控和告警', () => {
beforeEach(() => {
service.resetStats();
});
/**
* 属性: 对于任何告警,系统应该正确记录并更新统计
*/
it('对于任何告警,系统应该正确记录并更新统计', async () => {
await fc.assert(
fc.asyncProperty(
// 生成告警ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成告警级别
fc.constantFrom(
AlertLevel.INFO,
AlertLevel.WARNING,
AlertLevel.ERROR,
AlertLevel.CRITICAL
),
// 生成标题
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
// 生成消息
fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0),
// 生成组件名称
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
async (id, level, title, message, component) => {
const initialStats = service.getStats();
const initialTotal = initialStats.alerts.total;
const initialByLevel = initialStats.alerts.byLevel[level];
const alertHandler = jest.fn();
service.on('alert', alertHandler);
service.sendAlert({
id,
level,
title,
message,
component,
timestamp: new Date(),
});
const newStats = service.getStats();
// 验证总数增加
expect(newStats.alerts.total).toBe(initialTotal + 1);
// 验证级别计数增加
expect(newStats.alerts.byLevel[level]).toBe(initialByLevel + 1);
// 验证事件被发出
expect(alertHandler).toHaveBeenCalled();
service.removeListener('alert', alertHandler);
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 健康检查应该返回完整的状态信息
*/
it('健康检查应该返回完整的状态信息', async () => {
await fc.assert(
fc.asyncProperty(
// 生成一些连接事件
fc.integer({ min: 0, max: 10 }),
// 生成一些API调用
fc.integer({ min: 0, max: 10 }),
async (connectionCount, apiCallCount) => {
// 模拟一些活动
for (let i = 0; i < connectionCount; i++) {
service.logConnection({
socketId: `socket-${i}`,
eventType: ConnectionEventType.CONNECTED,
timestamp: new Date(),
});
}
for (let i = 0; i < apiCallCount; i++) {
service.logApiCall({
operation: `operation-${i}`,
userId: `user-${i}`,
result: ApiCallResult.SUCCESS,
responseTime: 100,
timestamp: new Date(),
});
}
const health = await service.checkSystemHealth();
// 验证健康状态结构
expect(health).toBeDefined();
expect(health.status).toBeDefined();
expect(['healthy', 'degraded', 'unhealthy']).toContain(health.status);
expect(health.components).toBeDefined();
expect(health.components.websocket).toBeDefined();
expect(health.components.zulipApi).toBeDefined();
expect(health.components.redis).toBeDefined();
expect(health.components.memory).toBeDefined();
expect(health.timestamp).toBeInstanceOf(Date);
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 最近告警列表应该正确维护
*/
it('最近告警列表应该正确维护', async () => {
await fc.assert(
fc.asyncProperty(
// 生成告警数量
fc.integer({ min: 1, max: 20 }),
// 生成请求数量
fc.integer({ min: 1, max: 15 }),
async (alertCount, requestLimit) => {
// 重置统计以确保干净的状态
service.resetStats();
// 发送多个告警
for (let i = 0; i < alertCount; i++) {
service.sendAlert({
id: `alert-${i}`,
level: AlertLevel.INFO,
title: `Alert ${i}`,
message: `Message ${i}`,
component: 'test',
timestamp: new Date(),
});
}
const recentAlerts = service.getRecentAlerts(requestLimit);
// 验证返回数量不超过请求数量
expect(recentAlerts.length).toBeLessThanOrEqual(requestLimit);
// 验证返回数量不超过实际告警数量(在本次测试中发送的)
expect(recentAlerts.length).toBeLessThanOrEqual(Math.min(alertCount, requestLimit));
// 验证每个告警都有必要的字段
for (const alert of recentAlerts) {
expect(alert.id).toBeDefined();
expect(alert.level).toBeDefined();
expect(alert.title).toBeDefined();
expect(alert.message).toBeDefined();
expect(alert.component).toBeDefined();
expect(alert.timestamp).toBeInstanceOf(Date);
}
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 统计数据应该保持一致性
*/
it('统计数据应该保持一致性', async () => {
await fc.assert(
fc.asyncProperty(
// 生成成功API调用数
fc.integer({ min: 0, max: 50 }),
// 生成失败API调用数
fc.integer({ min: 0, max: 50 }),
async (successCount, failureCount) => {
// 重置统计以确保干净的状态
service.resetStats();
// 记录成功的API调用
for (let i = 0; i < successCount; i++) {
service.logApiCall({
operation: 'test',
userId: 'user1',
result: ApiCallResult.SUCCESS,
responseTime: 100,
timestamp: new Date(),
});
}
// 记录失败的API调用
for (let i = 0; i < failureCount; i++) {
service.logApiCall({
operation: 'test',
userId: 'user1',
result: ApiCallResult.FAILURE,
responseTime: 100,
timestamp: new Date(),
});
}
const stats = service.getStats();
// 验证总数等于成功数加失败数
expect(stats.apiCalls.total).toBe(successCount + failureCount);
expect(stats.apiCalls.success).toBe(successCount);
expect(stats.apiCalls.failures).toBe(failureCount);
}
),
{ numRuns: 50 }
);
}, 30000);
});
});

View File

@@ -1,682 +0,0 @@
/**
* 系统监控服务
*
* 功能描述:
* - 记录连接、API调用、消息转发日志
* - 实现操作确认机制
* - 系统资源监控和告警
*
* 主要方法:
* - logConnection(): 记录连接日志
* - logApiCall(): 记录API调用日志
* - logMessageForward(): 记录消息转发日志
* - confirmOperation(): 操作确认
* - checkSystemHealth(): 系统健康检查
* - sendAlert(): 发送告警
*
* 使用场景:
* - WebSocket连接管理监控
* - Zulip API调用监控
* - 消息转发性能监控
* - 系统资源告警
*
* 依赖模块:
* - AppLoggerService: 日志记录服务
* - ConfigService: 配置服务
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter } from 'events';
/**
* 连接事件类型
*/
export enum ConnectionEventType {
CONNECTED = 'connected',
DISCONNECTED = 'disconnected',
ERROR = 'error',
TIMEOUT = 'timeout',
}
/**
* API调用结果类型
*/
export enum ApiCallResult {
SUCCESS = 'success',
FAILURE = 'failure',
TIMEOUT = 'timeout',
RATE_LIMITED = 'rate_limited',
}
/**
* 告警级别
*/
export enum AlertLevel {
INFO = 'info',
WARNING = 'warning',
ERROR = 'error',
CRITICAL = 'critical',
}
/**
* 连接日志接口
*/
export interface ConnectionLog {
socketId: string;
userId?: string;
eventType: ConnectionEventType;
timestamp: Date;
duration?: number;
error?: string;
metadata?: Record<string, any>;
}
/**
* API调用日志接口
*/
export interface ApiCallLog {
operation: string;
userId: string;
result: ApiCallResult;
responseTime: number;
timestamp: Date;
statusCode?: number;
error?: string;
metadata?: Record<string, any>;
}
/**
* 消息转发日志接口
*/
export interface MessageForwardLog {
messageId?: number;
fromUserId: string;
toUserIds: string[];
stream: string;
topic: string;
direction: 'upstream' | 'downstream';
success: boolean;
latency: number;
timestamp: Date;
error?: string;
}
/**
* 操作确认接口
*/
export interface OperationConfirmation {
operationId: string;
operation: string;
userId: string;
success: boolean;
timestamp: Date;
details?: Record<string, any>;
}
/**
* 系统健康状态接口
*/
export interface SystemHealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
components: {
websocket: ComponentHealth;
zulipApi: ComponentHealth;
redis: ComponentHealth;
memory: ComponentHealth;
};
timestamp: Date;
}
/**
* 组件健康状态接口
*/
export interface ComponentHealth {
status: 'healthy' | 'degraded' | 'unhealthy';
latency?: number;
errorRate?: number;
details?: Record<string, any>;
}
/**
* 告警接口
*/
export interface Alert {
id: string;
level: AlertLevel;
title: string;
message: string;
component: string;
timestamp: Date;
metadata?: Record<string, any>;
}
/**
* 监控统计接口
*/
export interface MonitoringStats {
connections: {
total: number;
active: number;
errors: number;
};
apiCalls: {
total: number;
success: number;
failures: number;
avgResponseTime: number;
};
messages: {
upstream: number;
downstream: number;
errors: number;
avgLatency: number;
};
alerts: {
total: number;
byLevel: Record<AlertLevel, number>;
};
}
@Injectable()
export class MonitoringService extends EventEmitter implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MonitoringService.name);
// 统计数据
private connectionStats = { total: 0, active: 0, errors: 0 };
private apiCallStats = { total: 0, success: 0, failures: 0, totalResponseTime: 0 };
private messageStats = { upstream: 0, downstream: 0, errors: 0, totalLatency: 0 };
private alertStats: Record<AlertLevel, number> = {
[AlertLevel.INFO]: 0,
[AlertLevel.WARNING]: 0,
[AlertLevel.ERROR]: 0,
[AlertLevel.CRITICAL]: 0,
};
// 最近的日志记录(用于分析)
private recentApiCalls: ApiCallLog[] = [];
private recentAlerts: Alert[] = [];
private readonly maxRecentLogs = 100;
// 健康检查间隔
private healthCheckInterval: NodeJS.Timeout | null = null;
private readonly healthCheckIntervalMs: number;
// 告警阈值
private readonly errorRateThreshold: number;
private readonly responseTimeThreshold: number;
private readonly memoryThreshold: number;
constructor(
private readonly configService: ConfigService,
) {
super();
// 从配置读取阈值
this.healthCheckIntervalMs = this.configService.get<number>('MONITORING_HEALTH_CHECK_INTERVAL', 60000);
this.errorRateThreshold = this.configService.get<number>('MONITORING_ERROR_RATE_THRESHOLD', 0.1);
this.responseTimeThreshold = this.configService.get<number>('MONITORING_RESPONSE_TIME_THRESHOLD', 5000);
this.memoryThreshold = this.configService.get<number>('MONITORING_MEMORY_THRESHOLD', 0.9);
this.logger.log('MonitoringService初始化完成');
}
/**
* 模块初始化时启动健康检查
*/
onModuleInit(): void {
this.startHealthCheck();
}
/**
* 模块销毁时清理资源
*/
onModuleDestroy(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
/**
* 记录连接日志
*
* 功能描述:
* 记录WebSocket连接建立、断开和异常日志
*
* @param log 连接日志
*/
logConnection(log: ConnectionLog): void {
// 更新统计
switch (log.eventType) {
case ConnectionEventType.CONNECTED:
this.connectionStats.total++;
this.connectionStats.active++;
break;
case ConnectionEventType.DISCONNECTED:
this.connectionStats.active = Math.max(0, this.connectionStats.active - 1);
break;
case ConnectionEventType.ERROR:
case ConnectionEventType.TIMEOUT:
this.connectionStats.errors++;
break;
}
// 记录日志
if (log.eventType === ConnectionEventType.ERROR) {
this.logger.warn(`WebSocket连接事件: ${log.eventType}`, {
operation: 'logConnection',
socketId: log.socketId,
userId: log.userId,
eventType: log.eventType,
duration: log.duration,
error: log.error,
...log.metadata,
timestamp: log.timestamp.toISOString(),
});
} else {
this.logger.log(`WebSocket连接事件: ${log.eventType}`);
}
// 发出事件
this.emit('connection_event', log);
}
/**
* 记录API调用日志
*
* 功能描述:
* 记录Zulip API调用的响应时间和结果
*
* @param log API调用日志
*/
logApiCall(log: ApiCallLog): void {
// 更新统计
this.apiCallStats.total++;
this.apiCallStats.totalResponseTime += log.responseTime;
if (log.result === ApiCallResult.SUCCESS) {
this.apiCallStats.success++;
} else {
this.apiCallStats.failures++;
}
// 保存最近的调用记录
this.recentApiCalls.push(log);
if (this.recentApiCalls.length > this.maxRecentLogs) {
this.recentApiCalls.shift();
}
// 记录日志
if (log.result === ApiCallResult.SUCCESS) {
this.logger.log(`Zulip API调用: ${log.operation}`);
} else {
this.logger.warn(`Zulip API调用: ${log.operation}`, {
operation: 'logApiCall',
apiOperation: log.operation,
userId: log.userId,
result: log.result,
responseTime: log.responseTime,
statusCode: log.statusCode,
error: log.error,
...log.metadata,
timestamp: log.timestamp.toISOString(),
});
}
// 检查是否需要告警
if (log.responseTime > this.responseTimeThreshold) {
this.sendAlert({
id: `api-slow-${Date.now()}`,
level: AlertLevel.WARNING,
title: 'API响应时间过长',
message: `API调用 ${log.operation} 响应时间 ${log.responseTime}ms 超过阈值 ${this.responseTimeThreshold}ms`,
component: 'zulip-api',
timestamp: new Date(),
metadata: { operation: log.operation, responseTime: log.responseTime },
});
}
// 发出事件
this.emit('api_call', log);
}
/**
* 记录消息转发日志
*
* 功能描述:
* 记录消息转发的成功率和延迟
*
* @param log 消息转发日志
*/
logMessageForward(log: MessageForwardLog): void {
// 更新统计
if (log.direction === 'upstream') {
this.messageStats.upstream++;
} else {
this.messageStats.downstream++;
}
if (!log.success) {
this.messageStats.errors++;
}
this.messageStats.totalLatency += log.latency;
// 记录日志
if (log.success) {
this.logger.log(`消息转发: ${log.direction}`);
} else {
this.logger.warn(`消息转发: ${log.direction}`, {
operation: 'logMessageForward',
messageId: log.messageId,
fromUserId: log.fromUserId,
toUserCount: log.toUserIds.length,
stream: log.stream,
topic: log.topic,
direction: log.direction,
success: log.success,
latency: log.latency,
error: log.error,
timestamp: log.timestamp.toISOString(),
});
}
// 发出事件
this.emit('message_forward', log);
}
/**
* 操作确认
*
* 功能描述:
* 记录操作确认信息,用于审计和追踪
*
* @param confirmation 操作确认信息
*/
confirmOperation(confirmation: OperationConfirmation): void {
this.logger.log(`操作确认: ${confirmation.operation}`);
// 发出事件
this.emit('operation_confirmed', confirmation);
}
/**
* 检查系统健康状态
*
* 功能描述:
* 检查各组件的健康状态,返回综合健康报告
*
* @returns Promise<SystemHealthStatus> 系统健康状态
*/
async checkSystemHealth(): Promise<SystemHealthStatus> {
const components = {
websocket: this.checkWebSocketHealth(),
zulipApi: this.checkZulipApiHealth(),
redis: await this.checkRedisHealth(),
memory: this.checkMemoryHealth(),
};
// 确定整体状态
const componentStatuses = Object.values(components).map(c => c.status);
let overallStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (componentStatuses.includes('unhealthy')) {
overallStatus = 'unhealthy';
} else if (componentStatuses.includes('degraded')) {
overallStatus = 'degraded';
}
const healthStatus: SystemHealthStatus = {
status: overallStatus,
components,
timestamp: new Date(),
};
this.logger.debug('系统健康检查完成', {
operation: 'checkSystemHealth',
status: overallStatus,
components: Object.fromEntries(
Object.entries(components).map(([k, v]) => [k, v.status])
),
timestamp: new Date().toISOString(),
});
// 如果状态不健康,发送告警
if (overallStatus !== 'healthy') {
this.sendAlert({
id: `health-${Date.now()}`,
level: overallStatus === 'unhealthy' ? AlertLevel.CRITICAL : AlertLevel.WARNING,
title: '系统健康状态异常',
message: `系统状态: ${overallStatus}`,
component: 'system',
timestamp: new Date(),
metadata: { components },
});
}
return healthStatus;
}
/**
* 发送告警
*
* 功能描述:
* 发送系统告警通知
*
* @param alert 告警信息
*/
sendAlert(alert: Alert): void {
// 更新统计
this.alertStats[alert.level]++;
// 保存最近的告警
this.recentAlerts.push(alert);
if (this.recentAlerts.length > this.maxRecentLogs) {
this.recentAlerts.shift();
}
// 根据级别选择日志方法
if (alert.level === AlertLevel.CRITICAL || alert.level === AlertLevel.ERROR) {
this.logger.error(`系统告警: ${alert.title}`, {
operation: 'sendAlert',
alertId: alert.id,
level: alert.level,
title: alert.title,
message: alert.message,
component: alert.component,
...alert.metadata,
timestamp: alert.timestamp.toISOString(),
});
} else if (alert.level === AlertLevel.WARNING) {
this.logger.warn(`系统告警: ${alert.title}`, {
operation: 'sendAlert',
alertId: alert.id,
level: alert.level,
title: alert.title,
message: alert.message,
component: alert.component,
...alert.metadata,
timestamp: alert.timestamp.toISOString(),
});
} else {
this.logger.log(`系统告警: ${alert.title}`);
}
// 发出事件
this.emit('alert', alert);
}
/**
* 获取监控统计信息
*
* @returns MonitoringStats 监控统计
*/
getStats(): MonitoringStats {
const totalApiCalls = this.apiCallStats.total || 1;
const totalMessages = this.messageStats.upstream + this.messageStats.downstream || 1;
return {
connections: { ...this.connectionStats },
apiCalls: {
total: this.apiCallStats.total,
success: this.apiCallStats.success,
failures: this.apiCallStats.failures,
avgResponseTime: this.apiCallStats.totalResponseTime / totalApiCalls,
},
messages: {
upstream: this.messageStats.upstream,
downstream: this.messageStats.downstream,
errors: this.messageStats.errors,
avgLatency: this.messageStats.totalLatency / totalMessages,
},
alerts: {
total: Object.values(this.alertStats).reduce((a, b) => a + b, 0),
byLevel: { ...this.alertStats },
},
};
}
/**
* 获取最近的告警
*
* @param limit 返回数量限制
* @returns Alert[] 最近的告警列表
*/
getRecentAlerts(limit: number = 10): Alert[] {
return this.recentAlerts.slice(-limit);
}
/**
* 重置统计数据
*/
resetStats(): void {
this.connectionStats = { total: 0, active: 0, errors: 0 };
this.apiCallStats = { total: 0, success: 0, failures: 0, totalResponseTime: 0 };
this.messageStats = { upstream: 0, downstream: 0, errors: 0, totalLatency: 0 };
this.alertStats = {
[AlertLevel.INFO]: 0,
[AlertLevel.WARNING]: 0,
[AlertLevel.ERROR]: 0,
[AlertLevel.CRITICAL]: 0,
};
this.recentApiCalls = [];
this.recentAlerts = [];
this.logger.log('监控统计数据已重置');
}
/**
* 启动健康检查
* @private
*/
private startHealthCheck(): void {
this.healthCheckInterval = setInterval(async () => {
await this.checkSystemHealth();
}, this.healthCheckIntervalMs);
this.logger.log('健康检查已启动');
}
/**
* 检查WebSocket健康状态
* @private
*/
private checkWebSocketHealth(): ComponentHealth {
const errorRate = this.connectionStats.total > 0
? this.connectionStats.errors / this.connectionStats.total
: 0;
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (errorRate > this.errorRateThreshold * 2) {
status = 'unhealthy';
} else if (errorRate > this.errorRateThreshold) {
status = 'degraded';
}
return {
status,
errorRate,
details: {
activeConnections: this.connectionStats.active,
totalConnections: this.connectionStats.total,
errors: this.connectionStats.errors,
},
};
}
/**
* 检查Zulip API健康状态
* @private
*/
private checkZulipApiHealth(): ComponentHealth {
const totalCalls = this.apiCallStats.total || 1;
const errorRate = this.apiCallStats.failures / totalCalls;
const avgResponseTime = this.apiCallStats.totalResponseTime / totalCalls;
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (errorRate > this.errorRateThreshold * 2 || avgResponseTime > this.responseTimeThreshold * 2) {
status = 'unhealthy';
} else if (errorRate > this.errorRateThreshold || avgResponseTime > this.responseTimeThreshold) {
status = 'degraded';
}
return {
status,
latency: avgResponseTime,
errorRate,
details: {
totalCalls: this.apiCallStats.total,
successCalls: this.apiCallStats.success,
failedCalls: this.apiCallStats.failures,
},
};
}
/**
* 检查Redis健康状态
* @private
*/
private async checkRedisHealth(): Promise<ComponentHealth> {
// 简单的健康检查实际应该ping Redis
return {
status: 'healthy',
details: {
note: 'Redis健康检查需要实际连接测试',
},
};
}
/**
* 检查内存健康状态
* @private
*/
private checkMemoryHealth(): ComponentHealth {
const memUsage = process.memoryUsage();
const heapUsedRatio = memUsage.heapUsed / memUsage.heapTotal;
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (heapUsedRatio > this.memoryThreshold) {
status = 'unhealthy';
} else if (heapUsedRatio > this.memoryThreshold * 0.8) {
status = 'degraded';
}
return {
status,
details: {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
heapUsedRatio,
rss: memUsage.rss,
external: memUsage.external,
},
};
}
}

View File

@@ -0,0 +1,650 @@
/**
* 会话清理定时任务服务测试
*
* 功能描述:
* - 测试SessionCleanupService的核心功能
* - 包含属性测试验证定时清理机制
* - 包含属性测试验证资源释放完整性
*
* **Feature: zulip-integration, Property 13: 定时清理机制**
* **Validates: Requirements 6.1, 6.2, 6.3**
*
* **Feature: zulip-integration, Property 14: 资源释放完整性**
* **Validates: Requirements 6.4, 6.5**
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-31
*/
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import {
SessionCleanupService,
CleanupConfig,
CleanupResult
} from './session_cleanup.service';
import { SessionManagerService } from './session_manager.service';
import { IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
describe('SessionCleanupService', () => {
let service: SessionCleanupService;
let mockSessionManager: jest.Mocked<SessionManagerService>;
let mockZulipClientPool: jest.Mocked<IZulipClientPoolService>;
// 模拟清理结果
const createMockCleanupResult = (overrides: Partial<any> = {}): any => ({
cleanedCount: 3,
zulipQueueIds: ['queue-1', 'queue-2', 'queue-3'],
duration: 150,
timestamp: new Date(),
...overrides,
});
beforeEach(async () => {
jest.clearAllMocks();
// Only use fake timers for tests that need them
// The concurrent test will use real timers for proper Promise handling
mockSessionManager = {
cleanupExpiredSessions: jest.fn(),
getSession: jest.fn(),
destroySession: jest.fn(),
createSession: jest.fn(),
updatePlayerPosition: jest.fn(),
getSocketsInMap: jest.fn(),
injectContext: jest.fn(),
} as any;
mockZulipClientPool = {
createUserClient: jest.fn(),
getUserClient: jest.fn(),
hasUserClient: jest.fn(),
sendMessage: jest.fn(),
registerEventQueue: jest.fn(),
deregisterEventQueue: jest.fn(),
destroyUserClient: jest.fn(),
getPoolStats: jest.fn(),
cleanupIdleClients: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
SessionCleanupService,
{
provide: SessionManagerService,
useValue: mockSessionManager,
},
{
provide: 'ZULIP_CLIENT_POOL_SERVICE',
useValue: mockZulipClientPool,
},
],
}).compile();
service = module.get<SessionCleanupService>(SessionCleanupService);
});
afterEach(() => {
service.stopCleanupTask();
// Only restore timers if they were faked
if (jest.isMockFunction(setTimeout)) {
jest.useRealTimers();
}
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('startCleanupTask - 启动清理任务', () => {
it('应该启动定时清理任务', () => {
service.startCleanupTask();
const status = service.getStatus();
expect(status.isEnabled).toBe(true);
});
it('应该在已启动时不重复启动', () => {
service.startCleanupTask();
service.startCleanupTask(); // 第二次调用
const status = service.getStatus();
expect(status.isEnabled).toBe(true);
});
it('应该立即执行一次清理', async () => {
jest.useFakeTimers();
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(
createMockCleanupResult({ cleanedCount: 2 })
);
service.startCleanupTask();
// 等待立即执行的清理完成
await jest.runOnlyPendingTimersAsync();
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30);
jest.useRealTimers();
});
});
describe('stopCleanupTask - 停止清理任务', () => {
it('应该停止定时清理任务', () => {
service.startCleanupTask();
service.stopCleanupTask();
const status = service.getStatus();
expect(status.isEnabled).toBe(false);
});
it('应该在未启动时安全停止', () => {
service.stopCleanupTask();
const status = service.getStatus();
expect(status.isEnabled).toBe(false);
});
});
describe('runCleanup - 执行清理', () => {
it('应该成功执行清理并返回结果', async () => {
const mockResult = createMockCleanupResult({
cleanedCount: 5,
zulipQueueIds: ['queue-1', 'queue-2', 'queue-3', 'queue-4', 'queue-5'],
});
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
const result = await service.runCleanup();
expect(result.success).toBe(true);
expect(result.cleanedSessions).toBe(5);
expect(result.deregisteredQueues).toBe(5);
expect(result.duration).toBeGreaterThanOrEqual(0); // 修改为 >= 0因为测试环境可能很快
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30);
});
it('应该处理清理过程中的错误', async () => {
const error = new Error('清理失败');
mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error);
const result = await service.runCleanup();
expect(result.success).toBe(false);
expect(result.error).toBe('清理失败');
expect(result.cleanedSessions).toBe(0);
expect(result.deregisteredQueues).toBe(0);
});
it('应该防止并发执行', async () => {
let resolveFirst: () => void;
const firstPromise = new Promise<any>(resolve => {
resolveFirst = () => resolve(createMockCleanupResult());
});
mockSessionManager.cleanupExpiredSessions.mockReturnValueOnce(firstPromise);
// 同时启动两个清理任务
const promise1 = service.runCleanup();
const promise2 = service.runCleanup();
// 第二个应该立即返回失败
const result2 = await promise2;
expect(result2.success).toBe(false);
expect(result2.error).toContain('正在执行中');
// 完成第一个任务
resolveFirst!();
const result1 = await promise1;
expect(result1.success).toBe(true);
}, 15000);
it('应该记录最后一次清理结果', async () => {
const mockResult = createMockCleanupResult({ cleanedCount: 3 });
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
await service.runCleanup();
const lastResult = service.getLastCleanupResult();
expect(lastResult).not.toBeNull();
expect(lastResult!.cleanedSessions).toBe(3);
expect(lastResult!.success).toBe(true);
});
});
describe('getStatus - 获取状态', () => {
it('应该返回正确的状态信息', () => {
const status = service.getStatus();
expect(status).toHaveProperty('isRunning');
expect(status).toHaveProperty('isEnabled');
expect(status).toHaveProperty('config');
expect(status).toHaveProperty('lastResult');
expect(typeof status.isRunning).toBe('boolean');
expect(typeof status.isEnabled).toBe('boolean');
});
it('应该反映任务启动状态', () => {
let status = service.getStatus();
expect(status.isEnabled).toBe(false);
service.startCleanupTask();
status = service.getStatus();
expect(status.isEnabled).toBe(true);
service.stopCleanupTask();
status = service.getStatus();
expect(status.isEnabled).toBe(false);
});
});
describe('updateConfig - 更新配置', () => {
it('应该更新清理配置', () => {
const newConfig: Partial<CleanupConfig> = {
intervalMs: 10 * 60 * 1000, // 10分钟
sessionTimeoutMinutes: 60, // 60分钟
};
service.updateConfig(newConfig);
const status = service.getStatus();
expect(status.config.intervalMs).toBe(10 * 60 * 1000);
expect(status.config.sessionTimeoutMinutes).toBe(60);
});
it('应该在配置更改后重启任务', () => {
service.startCleanupTask();
const newConfig: Partial<CleanupConfig> = {
intervalMs: 2 * 60 * 1000, // 2分钟
};
service.updateConfig(newConfig);
const status = service.getStatus();
expect(status.isEnabled).toBe(true);
expect(status.config.intervalMs).toBe(2 * 60 * 1000);
});
it('应该支持禁用清理任务', () => {
service.startCleanupTask();
service.updateConfig({ enabled: false });
const status = service.getStatus();
expect(status.isEnabled).toBe(false);
});
});
/**
* 属性测试: 定时清理机制
*
* **Feature: zulip-integration, Property 13: 定时清理机制**
* **Validates: Requirements 6.1, 6.2, 6.3**
*
* 系统应该定期清理过期的游戏会话,释放相关资源,
* 并确保清理过程不影响正常的游戏服务
*/
describe('Property 13: 定时清理机制', () => {
/**
* 属性: 对于任何有效的清理配置,系统应该按配置间隔执行清理
* 验证需求 6.1: 系统应定期检查并清理过期的游戏会话
*/
it('对于任何有效的清理配置,系统应该按配置间隔执行清理', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的清理间隔1-10分钟
fc.integer({ min: 1, max: 10 }).map(minutes => minutes * 60 * 1000),
// 生成有效的会话超时时间10-120分钟
fc.integer({ min: 10, max: 120 }),
async (intervalMs, sessionTimeoutMinutes) => {
// 重置mock以确保每次测试都是干净的状态
jest.clearAllMocks();
jest.useFakeTimers();
const config: Partial<CleanupConfig> = {
intervalMs,
sessionTimeoutMinutes,
enabled: true,
};
// 模拟清理结果
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(
createMockCleanupResult({ cleanedCount: 2 })
);
service.updateConfig(config);
service.startCleanupTask();
// 验证配置被正确设置
const status = service.getStatus();
expect(status.config.intervalMs).toBe(intervalMs);
expect(status.config.sessionTimeoutMinutes).toBe(sessionTimeoutMinutes);
expect(status.isEnabled).toBe(true);
// 验证立即执行了一次清理
await jest.runOnlyPendingTimersAsync();
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(sessionTimeoutMinutes);
service.stopCleanupTask();
jest.useRealTimers();
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 对于任何清理操作,都应该记录清理结果和统计信息
* 验证需求 6.2: 清理过程中系统应记录清理的会话数量和释放的资源
*/
it('对于任何清理操作,都应该记录清理结果和统计信息', async () => {
await fc.assert(
fc.asyncProperty(
// 生成清理的会话数量
fc.integer({ min: 0, max: 20 }),
// 生成Zulip队列ID列表
fc.array(
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
{ minLength: 0, maxLength: 20 }
),
async (cleanedCount, queueIds) => {
// 重置mock以确保每次测试都是干净的状态
jest.clearAllMocks();
const mockResult = createMockCleanupResult({
cleanedCount,
zulipQueueIds: queueIds.slice(0, cleanedCount), // 确保队列数量不超过清理数量
});
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
const result = await service.runCleanup();
// 验证清理结果被正确记录
expect(result.success).toBe(true);
expect(result.cleanedSessions).toBe(cleanedCount);
expect(result.deregisteredQueues).toBe(Math.min(queueIds.length, cleanedCount));
expect(result.duration).toBeGreaterThanOrEqual(0);
expect(result.timestamp).toBeInstanceOf(Date);
// 验证最后一次清理结果被保存
const lastResult = service.getLastCleanupResult();
expect(lastResult).not.toBeNull();
expect(lastResult!.cleanedSessions).toBe(cleanedCount);
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 清理过程中发生错误时,系统应该正确处理并记录错误信息
* 验证需求 6.3: 清理过程中出现错误时系统应记录错误信息并继续正常服务
*/
it('清理过程中发生错误时,系统应该正确处理并记录错误信息', async () => {
await fc.assert(
fc.asyncProperty(
// 生成各种错误消息
fc.string({ minLength: 5, maxLength: 100 }).filter(s => s.trim().length > 0),
async (errorMessage) => {
// 重置mock以确保每次测试都是干净的状态
jest.clearAllMocks();
const error = new Error(errorMessage.trim());
mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error);
const result = await service.runCleanup();
// 验证错误被正确处理
expect(result.success).toBe(false);
expect(result.error).toBe(errorMessage.trim());
expect(result.cleanedSessions).toBe(0);
expect(result.deregisteredQueues).toBe(0);
expect(result.duration).toBeGreaterThanOrEqual(0);
// 验证错误结果被保存
const lastResult = service.getLastCleanupResult();
expect(lastResult).not.toBeNull();
expect(lastResult!.success).toBe(false);
expect(lastResult!.error).toBe(errorMessage.trim());
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 并发清理请求应该被正确处理,避免重复执行
* 验证需求 6.1: 系统应避免同时执行多个清理任务
*/
it('并发清理请求应该被正确处理,避免重复执行', async () => {
// 重置mock
jest.clearAllMocks();
// 创建一个可控的Promise使用实际的异步行为
let resolveCleanup: (value: any) => void;
const cleanupPromise = new Promise<any>(resolve => {
resolveCleanup = resolve;
});
mockSessionManager.cleanupExpiredSessions.mockReturnValue(cleanupPromise);
// 启动第一个清理请求(应该成功)
const promise1 = service.runCleanup();
// 等待一个微任务周期,确保第一个请求开始执行
await Promise.resolve();
// 启动第二个和第三个清理请求(应该被拒绝)
const promise2 = service.runCleanup();
const promise3 = service.runCleanup();
// 第二个和第三个请求应该立即返回失败
const result2 = await promise2;
const result3 = await promise3;
expect(result2.success).toBe(false);
expect(result2.error).toContain('正在执行中');
expect(result3.success).toBe(false);
expect(result3.error).toContain('正在执行中');
// 完成第一个清理操作
resolveCleanup!(createMockCleanupResult({ cleanedCount: 1 }));
const result1 = await promise1;
expect(result1.success).toBe(true);
}, 10000);
});
/**
* 属性测试: 资源释放完整性
*
* **Feature: zulip-integration, Property 14: 资源释放完整性**
* **Validates: Requirements 6.4, 6.5**
*
* 清理过期会话时,系统应该完整释放所有相关资源,
* 包括Zulip事件队列、内存缓存等确保不会造成资源泄漏
*/
describe('Property 14: 资源释放完整性', () => {
/**
* 属性: 对于任何过期会话清理时应该释放所有相关的Zulip资源
* 验证需求 6.4: 清理会话时系统应注销对应的Zulip事件队列
*/
it('对于任何过期会话清理时应该释放所有相关的Zulip资源', async () => {
await fc.assert(
fc.asyncProperty(
// 生成过期会话数量
fc.integer({ min: 1, max: 10 }),
// 生成每个会话对应的Zulip队列ID
fc.array(
fc.string({ minLength: 8, maxLength: 20 }).filter(s => s.trim().length > 0),
{ minLength: 1, maxLength: 10 }
),
async (sessionCount, queueIds) => {
// 重置mock以确保每次测试都是干净的状态
jest.clearAllMocks();
const actualQueueIds = queueIds.slice(0, sessionCount);
const mockResult = createMockCleanupResult({
cleanedCount: sessionCount,
zulipQueueIds: actualQueueIds,
});
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
const result = await service.runCleanup();
// 验证清理成功
expect(result.success).toBe(true);
expect(result.cleanedSessions).toBe(sessionCount);
// 验证Zulip队列被处理这里简化为计数验证
expect(result.deregisteredQueues).toBe(actualQueueIds.length);
// 验证SessionManager被调用清理过期会话
expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30);
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 清理操作应该是原子性的,要么全部成功要么全部回滚
* 验证需求 6.5: 清理过程应确保数据一致性,避免部分清理导致的不一致状态
*/
it('清理操作应该是原子性的,要么全部成功要么全部回滚', async () => {
await fc.assert(
fc.asyncProperty(
// 生成是否模拟清理失败
fc.boolean(),
// 生成会话数量
fc.integer({ min: 1, max: 5 }),
async (shouldFail, sessionCount) => {
// 重置mock以确保每次测试都是干净的状态
jest.clearAllMocks();
if (shouldFail) {
// 模拟清理失败
const error = new Error('清理操作失败');
mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error);
} else {
// 模拟清理成功
const mockResult = createMockCleanupResult({
cleanedCount: sessionCount,
zulipQueueIds: Array.from({ length: sessionCount }, (_, i) => `queue-${i}`),
});
mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult);
}
const result = await service.runCleanup();
if (shouldFail) {
// 失败时应该没有任何资源被释放
expect(result.success).toBe(false);
expect(result.cleanedSessions).toBe(0);
expect(result.deregisteredQueues).toBe(0);
expect(result.error).toBeDefined();
} else {
// 成功时所有资源都应该被正确处理
expect(result.success).toBe(true);
expect(result.cleanedSessions).toBe(sessionCount);
expect(result.deregisteredQueues).toBe(sessionCount);
expect(result.error).toBeUndefined();
}
// 验证结果的一致性
expect(result.timestamp).toBeInstanceOf(Date);
expect(result.duration).toBeGreaterThanOrEqual(0);
}
),
{ numRuns: 50 }
);
}, 30000);
/**
* 属性: 清理配置更新应该正确重启清理任务而不丢失状态
* 验证需求 6.5: 配置更新时系统应保持服务连续性
*/
it('清理配置更新应该正确重启清理任务而不丢失状态', async () => {
await fc.assert(
fc.asyncProperty(
// 生成初始配置
fc.record({
intervalMs: fc.integer({ min: 1, max: 5 }).map(m => m * 60 * 1000),
sessionTimeoutMinutes: fc.integer({ min: 10, max: 60 }),
}),
// 生成新配置
fc.record({
intervalMs: fc.integer({ min: 1, max: 5 }).map(m => m * 60 * 1000),
sessionTimeoutMinutes: fc.integer({ min: 10, max: 60 }),
}),
async (initialConfig, newConfig) => {
// 重置mock以确保每次测试都是干净的状态
jest.clearAllMocks();
// 设置初始配置并启动任务
service.updateConfig(initialConfig);
service.startCleanupTask();
let status = service.getStatus();
expect(status.isEnabled).toBe(true);
expect(status.config.intervalMs).toBe(initialConfig.intervalMs);
// 更新配置
service.updateConfig(newConfig);
// 验证配置更新后任务仍在运行
status = service.getStatus();
expect(status.isEnabled).toBe(true);
expect(status.config.intervalMs).toBe(newConfig.intervalMs);
expect(status.config.sessionTimeoutMinutes).toBe(newConfig.sessionTimeoutMinutes);
service.stopCleanupTask();
}
),
{ numRuns: 30 }
);
}, 30000);
});
describe('模块生命周期', () => {
it('应该在模块初始化时启动清理任务', async () => {
// 重新创建服务实例来测试模块初始化
const module: TestingModule = await Test.createTestingModule({
providers: [
SessionCleanupService,
{
provide: SessionManagerService,
useValue: mockSessionManager,
},
{
provide: 'ZULIP_CLIENT_POOL_SERVICE',
useValue: mockZulipClientPool,
},
],
}).compile();
const newService = module.get<SessionCleanupService>(SessionCleanupService);
// 模拟模块初始化
await newService.onModuleInit();
const status = newService.getStatus();
expect(status.isEnabled).toBe(true);
// 清理
await newService.onModuleDestroy();
});
it('应该在模块销毁时停止清理任务', async () => {
service.startCleanupTask();
await service.onModuleDestroy();
const status = service.getStatus();
expect(status.isEnabled).toBe(false);
});
});
});

View File

@@ -21,9 +21,9 @@
* @since 2025-12-25
*/
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { SessionManagerService } from './session-manager.service';
import { ZulipClientPoolService } from './zulip-client-pool.service';
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
import { SessionManagerService } from './session_manager.service';
import { IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
/**
*
@@ -55,6 +55,28 @@ export interface CleanupResult {
error?: string;
}
/**
*
*
*
* -
* - Zulip客户端资源
* -
* -
*
*
* - startCleanup():
* - stopCleanup():
* - performCleanup():
* - getCleanupStats():
* - updateConfig():
*
* 使
* -
* -
* -
* -
*/
@Injectable()
export class SessionCleanupService implements OnModuleInit, OnModuleDestroy {
private cleanupInterval: NodeJS.Timeout | null = null;
@@ -70,7 +92,8 @@ export class SessionCleanupService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly sessionManager: SessionManagerService,
private readonly zulipClientPool: ZulipClientPoolService,
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
) {
this.logger.log('SessionCleanupService初始化完成');
}
@@ -176,7 +199,8 @@ export class SessionCleanupService implements OnModuleInit, OnModuleDestroy {
// 2. 注销对应的Zulip事件队列
let deregisteredQueues = 0;
for (const queueId of cleanupResult.zulipQueueIds) {
const queueIds = cleanupResult?.zulipQueueIds || [];
for (const queueId of queueIds) {
try {
// 根据queueId找到对应的用户并注销队列
// 注意这里需要通过某种方式找到queueId对应的userId
@@ -200,7 +224,7 @@ export class SessionCleanupService implements OnModuleInit, OnModuleDestroy {
const duration = Date.now() - startTime;
const result: CleanupResult = {
cleanedSessions: cleanupResult.cleanedCount,
cleanedSessions: cleanupResult?.cleanedCount || 0,
deregisteredQueues,
duration,
timestamp: new Date(),

View File

@@ -12,8 +12,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import { SessionManagerService, GameSession, Position } from './session-manager.service';
import { ConfigManagerService } from './config-manager.service';
import { SessionManagerService, GameSession, Position } from './session_manager.service';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
import { IRedisService } from '../../../core/redis/redis.interface';
@@ -21,7 +21,7 @@ describe('SessionManagerService', () => {
let service: SessionManagerService;
let mockLogger: jest.Mocked<AppLoggerService>;
let mockRedisService: jest.Mocked<IRedisService>;
let mockConfigManager: jest.Mocked<ConfigManagerService>;
let mockConfigManager: jest.Mocked<IZulipConfigService>;
// 内存存储模拟Redis
let memoryStore: Map<string, { value: string; expireAt?: number }>;
@@ -57,9 +57,15 @@ describe('SessionManagerService', () => {
};
return streamMap[mapId] || 'General';
}),
getMapIdByStream: jest.fn(),
getTopicByObject: jest.fn().mockReturnValue('General'),
getMapConfig: jest.fn(),
getAllMaps: jest.fn(),
getZulipConfig: jest.fn(),
hasMap: jest.fn(),
hasStream: jest.fn(),
getAllMapIds: jest.fn(),
getAllStreams: jest.fn(),
reloadConfig: jest.fn(),
validateConfig: jest.fn(),
} as any;
// 创建模拟Redis服务使用内存存储
@@ -135,7 +141,7 @@ describe('SessionManagerService', () => {
useValue: mockRedisService,
},
{
provide: ConfigManagerService,
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigManager,
},
],

View File

@@ -35,8 +35,8 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { IRedisService } from '../../../core/redis/redis.interface';
import { ConfigManagerService } from './config-manager.service';
import { Internal, Constants } from '../interfaces/zulip.interfaces';
import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { Internal, Constants } from '../../../core/zulip/interfaces/zulip.interfaces';
/**
* -
@@ -78,6 +78,29 @@ export interface SessionStats {
newestSession?: Date;
}
/**
*
*
*
* - WebSocket连接ID与Zulip队列ID的映射关系
* -
* -
* -
*
*
* - createSession(): Socket_ID与Zulip_Queue_ID
* - getSession():
* - injectContext(): Stream/Topic
* - getSocketsInMap(): Socket
* - updatePlayerPosition():
* - destroySession():
*
* 使
* -
* -
* -
* -
*/
@Injectable()
export class SessionManagerService {
private readonly SESSION_PREFIX = 'zulip:session:';
@@ -91,7 +114,8 @@ export class SessionManagerService {
constructor(
@Inject('REDIS_SERVICE')
private readonly redisService: IRedisService,
private readonly configManager: ConfigManagerService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
) {
this.logger.log('SessionManagerService初始化完成');
}
@@ -170,6 +194,9 @@ export class SessionManagerService {
* @param initialMap
* @param initialPosition
* @returns Promise<GameSession>
*
* @throws Error
* @throws Error Redis操作失败时
*/
async createSession(
socketId: string,
@@ -378,6 +405,8 @@ export class SessionManagerService {
* @param socketId WebSocket连接ID
* @param mapId ID
* @returns Promise<ContextInfo>
*
* @throws Error
*/
async injectContext(socketId: string, mapId?: string): Promise<ContextInfo> {
this.logger.debug('开始上下文注入', {

View File

@@ -1,331 +0,0 @@
/**
* Stream初始化服务
*
* 功能描述:
* - 在系统启动时检查并创建所有地图对应的Zulip Streams
* - 确保所有配置的Streams在Zulip服务器上存在
* - 提供Stream创建和验证功能
*
* 主要方法:
* - initializeStreams(): 初始化所有Streams
* - checkStreamExists(): 检查Stream是否存在
* - createStream(): 创建Stream
*
* 使用场景:
* - 系统启动时自动初始化
* - 配置更新后重新初始化
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigManagerService } from './config-manager.service';
@Injectable()
export class StreamInitializerService implements OnModuleInit {
private readonly logger = new Logger(StreamInitializerService.name);
private initializationComplete = false;
constructor(
private readonly configManager: ConfigManagerService,
) {
this.logger.log('StreamInitializerService初始化完成');
}
/**
* 模块初始化时自动执行
*/
async onModuleInit(): Promise<void> {
// 延迟5秒执行确保其他服务已初始化
setTimeout(async () => {
await this.initializeStreams();
}, 5000);
}
/**
* 初始化所有Streams
*
* 功能描述:
* 检查配置中的所有Streams是否存在不存在则创建
*
* @returns Promise<{success: boolean, created: string[], existing: string[], failed: string[]}>
*/
async initializeStreams(): Promise<{
success: boolean;
created: string[];
existing: string[];
failed: string[];
}> {
this.logger.log('开始初始化Zulip Streams', {
operation: 'initializeStreams',
timestamp: new Date().toISOString(),
});
const created: string[] = [];
const existing: string[] = [];
const failed: string[] = [];
try {
// 获取所有地图配置
const mapConfigs = this.configManager.getAllMapConfigs();
if (mapConfigs.length === 0) {
this.logger.warn('没有找到地图配置跳过Stream初始化', {
operation: 'initializeStreams',
});
return { success: true, created, existing, failed };
}
// 获取所有唯一的Stream名称
const streamNames = new Set<string>();
mapConfigs.forEach(config => {
streamNames.add(config.zulipStream);
});
this.logger.log(`找到 ${streamNames.size} 个需要检查的Streams`, {
operation: 'initializeStreams',
streamCount: streamNames.size,
streams: Array.from(streamNames),
});
// 检查并创建每个Stream
for (const streamName of streamNames) {
try {
const exists = await this.checkStreamExists(streamName);
if (exists) {
existing.push(streamName);
this.logger.log(`Stream已存在: ${streamName}`, {
operation: 'initializeStreams',
streamName,
});
} else {
const createResult = await this.createStream(streamName);
if (createResult) {
created.push(streamName);
this.logger.log(`Stream创建成功: ${streamName}`, {
operation: 'initializeStreams',
streamName,
});
} else {
failed.push(streamName);
this.logger.warn(`Stream创建失败: ${streamName}`, {
operation: 'initializeStreams',
streamName,
});
}
}
} catch (error) {
const err = error as Error;
failed.push(streamName);
this.logger.error(`处理Stream失败: ${streamName}`, {
operation: 'initializeStreams',
streamName,
error: err.message,
});
}
}
this.initializationComplete = true;
const success = failed.length === 0;
this.logger.log('Stream初始化完成', {
operation: 'initializeStreams',
success,
totalStreams: streamNames.size,
created: created.length,
existing: existing.length,
failed: failed.length,
timestamp: new Date().toISOString(),
});
return { success, created, existing, failed };
} catch (error) {
const err = error as Error;
this.logger.error('Stream初始化失败', {
operation: 'initializeStreams',
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return { success: false, created, existing, failed };
}
}
/**
* 检查Stream是否存在
*
* 功能描述:
* 使用Bot API Key检查指定的Stream是否在Zulip服务器上存在
*
* @param streamName Stream名称
* @returns Promise<boolean> 是否存在
*/
private async checkStreamExists(streamName: string): Promise<boolean> {
try {
// 获取Zulip配置
const zulipConfig = this.configManager.getZulipConfig();
if (!zulipConfig.zulipBotApiKey) {
this.logger.warn('Bot API Key未配置跳过Stream检查', {
operation: 'checkStreamExists',
streamName,
});
return false;
}
// 动态导入zulip-js
const zulipModule: any = await import('zulip-js');
const zulipFactory = zulipModule.default || zulipModule;
// 创建Bot客户端
const client = await zulipFactory({
username: zulipConfig.zulipBotEmail,
apiKey: zulipConfig.zulipBotApiKey,
realm: zulipConfig.zulipServerUrl,
});
// 获取所有Streams
const result = await client.streams.retrieve();
if (result.result === 'success' && result.streams) {
const exists = result.streams.some(
(stream: any) => stream.name.toLowerCase() === streamName.toLowerCase()
);
return exists;
}
return false;
} catch (error) {
const err = error as Error;
this.logger.error('检查Stream失败', {
operation: 'checkStreamExists',
streamName,
error: err.message,
});
return false;
}
}
/**
* 创建Stream
*
* 功能描述:
* 使用Bot API Key在Zulip服务器上创建新的Stream
*
* @param streamName Stream名称
* @param description Stream描述可选
* @returns Promise<boolean> 是否创建成功
*/
private async createStream(
streamName: string,
description?: string
): Promise<boolean> {
try {
// 获取Zulip配置
const zulipConfig = this.configManager.getZulipConfig();
if (!zulipConfig.zulipBotApiKey) {
this.logger.warn('Bot API Key未配置无法创建Stream', {
operation: 'createStream',
streamName,
});
return false;
}
// 动态导入zulip-js
const zulipModule: any = await import('zulip-js');
const zulipFactory = zulipModule.default || zulipModule;
// 创建Bot客户端
const client = await zulipFactory({
username: zulipConfig.zulipBotEmail,
apiKey: zulipConfig.zulipBotApiKey,
realm: zulipConfig.zulipServerUrl,
});
// 查找对应的地图配置以获取描述
const mapConfig = this.configManager.getMapConfigByStream(streamName);
const streamDescription = description ||
(mapConfig ? `${mapConfig.mapName} - ${mapConfig.description || 'Game chat channel'}` :
`Game chat channel for ${streamName}`);
// 使用callEndpoint创建Stream
const result = await client.callEndpoint(
'/users/me/subscriptions',
'POST',
{
subscriptions: JSON.stringify([
{
name: streamName,
description: streamDescription
}
])
}
);
if (result.result === 'success') {
this.logger.log('Stream创建成功', {
operation: 'createStream',
streamName,
description: streamDescription,
});
return true;
} else {
this.logger.warn('Stream创建失败', {
operation: 'createStream',
streamName,
error: result.msg,
});
return false;
}
} catch (error) {
const err = error as Error;
this.logger.error('创建Stream异常', {
operation: 'createStream',
streamName,
error: err.message,
}, err.stack);
return false;
}
}
/**
* 检查初始化是否完成
*
* @returns boolean 是否完成
*/
isInitializationComplete(): boolean {
return this.initializationComplete;
}
/**
* 手动触发Stream初始化
*
* 功能描述:
* 允许手动触发Stream初始化用于配置更新后重新初始化
*
* @returns Promise<{success: boolean, created: string[], existing: string[], failed: string[]}>
*/
async reinitializeStreams(): Promise<{
success: boolean;
created: string[];
existing: string[];
failed: string[];
}> {
this.logger.log('手动触发Stream重新初始化', {
operation: 'reinitializeStreams',
timestamp: new Date().toISOString(),
});
this.initializationComplete = false;
return await this.initializeStreams();
}
}

View File

@@ -1,519 +0,0 @@
/**
* Zulip客户端池服务测试
*
* 功能描述:
* - 测试ZulipClientPoolService的核心功能
* - 测试客户端创建和销毁流程
* - 测试事件队列管理
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ZulipClientPoolService, PoolStats } from './zulip-client-pool.service';
import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip-client.service';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
describe('ZulipClientPoolService', () => {
let service: ZulipClientPoolService;
let mockZulipClientService: jest.Mocked<ZulipClientService>;
let mockLogger: jest.Mocked<AppLoggerService>;
const createMockClientInstance = (userId: string, queueId?: string): ZulipClientInstance => ({
userId,
config: {
username: `${userId}@example.com`,
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
},
client: {},
queueId,
lastEventId: queueId ? 0 : -1,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
});
beforeEach(async () => {
jest.clearAllMocks();
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any;
mockZulipClientService = {
createClient: jest.fn(),
validateApiKey: jest.fn(),
sendMessage: jest.fn(),
registerQueue: jest.fn(),
deregisterQueue: jest.fn(),
getEvents: jest.fn(),
destroyClient: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
ZulipClientPoolService,
{
provide: ZulipClientService,
useValue: mockZulipClientService,
},
{
provide: AppLoggerService,
useValue: mockLogger,
},
],
}).compile();
service = module.get<ZulipClientPoolService>(ZulipClientPoolService);
});
afterEach(async () => {
// 清理所有客户端
await service.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createUserClient - 客户端创建', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该成功创建新的用户客户端', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
const result = await service.createUserClient('user1', config);
expect(result).toBeDefined();
expect(result.userId).toBe('user1');
expect(result.isValid).toBe(true);
expect(mockZulipClientService.createClient).toHaveBeenCalledWith('user1', config);
expect(mockZulipClientService.registerQueue).toHaveBeenCalled();
});
it('应该返回已存在的有效客户端', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
// 第一次创建
await service.createUserClient('user1', config);
// 第二次应该返回已存在的客户端
const result = await service.createUserClient('user1', config);
expect(result).toBeDefined();
expect(result.userId).toBe('user1');
// createClient只应该被调用一次
expect(mockZulipClientService.createClient).toHaveBeenCalledTimes(1);
});
it('应该在事件队列注册失败时抛出错误', async () => {
const mockInstance = createMockClientInstance('user1');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: false,
error: '队列注册失败',
});
await expect(service.createUserClient('user1', config))
.rejects.toThrow('事件队列注册失败');
});
it('应该在客户端创建失败时抛出错误', async () => {
mockZulipClientService.createClient.mockRejectedValue(new Error('API Key无效'));
await expect(service.createUserClient('user1', config))
.rejects.toThrow('API Key无效');
});
});
describe('getUserClient - 获取客户端', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该返回已存在的客户端', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
await service.createUserClient('user1', config);
const result = await service.getUserClient('user1');
expect(result).toBeDefined();
expect(result?.userId).toBe('user1');
});
it('应该在客户端不存在时返回null', async () => {
const result = await service.getUserClient('nonexistent');
expect(result).toBeNull();
});
});
describe('hasUserClient - 检查客户端存在', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该在客户端存在时返回true', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
await service.createUserClient('user1', config);
expect(service.hasUserClient('user1')).toBe(true);
});
it('应该在客户端不存在时返回false', () => {
expect(service.hasUserClient('nonexistent')).toBe(false);
});
});
describe('destroyUserClient - 销毁客户端', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该成功销毁客户端', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.destroyClient.mockResolvedValue(undefined);
await service.createUserClient('user1', config);
expect(service.hasUserClient('user1')).toBe(true);
await service.destroyUserClient('user1');
expect(service.hasUserClient('user1')).toBe(false);
expect(mockZulipClientService.destroyClient).toHaveBeenCalled();
});
it('应该在客户端不存在时静默处理', async () => {
await expect(service.destroyUserClient('nonexistent')).resolves.not.toThrow();
});
it('应该在销毁失败时仍从池中移除客户端', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.destroyClient.mockRejectedValue(new Error('销毁失败'));
await service.createUserClient('user1', config);
await service.destroyUserClient('user1');
expect(service.hasUserClient('user1')).toBe(false);
});
});
describe('sendMessage - 发送消息', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该成功发送消息', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.sendMessage.mockResolvedValue({
success: true,
messageId: 12345,
});
await service.createUserClient('user1', config);
const result = await service.sendMessage('user1', 'test-stream', 'test-topic', 'Hello');
expect(result.success).toBe(true);
expect(result.messageId).toBe(12345);
});
it('应该在客户端不存在时返回错误', async () => {
const result = await service.sendMessage('nonexistent', 'test-stream', 'test-topic', 'Hello');
expect(result.success).toBe(false);
expect(result.error).toContain('不存在');
});
});
describe('registerEventQueue - 事件队列注册', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该成功注册事件队列', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-new',
lastEventId: 0,
});
await service.createUserClient('user1', config);
// 重新注册队列
const result = await service.registerEventQueue('user1');
expect(result.success).toBe(true);
expect(result.queueId).toBe('queue-new');
});
it('应该在客户端不存在时返回错误', async () => {
const result = await service.registerEventQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toContain('不存在');
});
});
describe('deregisterEventQueue - 事件队列注销', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该成功注销事件队列', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.deregisterQueue.mockResolvedValue(true);
await service.createUserClient('user1', config);
const result = await service.deregisterEventQueue('user1');
expect(result).toBe(true);
expect(mockZulipClientService.deregisterQueue).toHaveBeenCalled();
});
it('应该在客户端不存在时返回true', async () => {
const result = await service.deregisterEventQueue('nonexistent');
expect(result).toBe(true);
});
});
describe('getPoolStats - 获取池统计信息', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该返回正确的统计信息', async () => {
const mockInstance1 = createMockClientInstance('user1', 'queue-1');
const mockInstance2 = createMockClientInstance('user2', 'queue-2');
mockZulipClientService.createClient
.mockResolvedValueOnce(mockInstance1)
.mockResolvedValueOnce(mockInstance2);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
await service.createUserClient('user1', config);
await service.createUserClient('user2', { ...config, username: 'user2@example.com' });
const stats = service.getPoolStats();
expect(stats.totalClients).toBe(2);
expect(stats.clientIds).toContain('user1');
expect(stats.clientIds).toContain('user2');
});
it('应该在池为空时返回零', () => {
const stats = service.getPoolStats();
expect(stats.totalClients).toBe(0);
expect(stats.activeClients).toBe(0);
expect(stats.clientIds).toHaveLength(0);
});
});
describe('cleanupIdleClients - 清理过期客户端', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该清理过期的客户端', async () => {
// 创建一个过期的客户端实例
const mockInstance = createMockClientInstance('user1', 'queue-123');
// 设置最后活动时间为1小时前
mockInstance.lastActivity = new Date(Date.now() - 60 * 60 * 1000);
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.destroyClient.mockResolvedValue(undefined);
await service.createUserClient('user1', config);
// 清理30分钟未活动的客户端
const cleanedCount = await service.cleanupIdleClients(30);
expect(cleanedCount).toBe(1);
expect(service.hasUserClient('user1')).toBe(false);
});
it('应该保留活跃的客户端', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
// 最后活动时间为现在
mockInstance.lastActivity = new Date();
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
await service.createUserClient('user1', config);
const cleanedCount = await service.cleanupIdleClients(30);
expect(cleanedCount).toBe(0);
expect(service.hasUserClient('user1')).toBe(true);
});
});
describe('事件轮询', () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'test-api-key',
realm: 'https://zulip.example.com',
};
it('应该启动和停止事件轮询', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.getEvents.mockResolvedValue({
success: true,
events: [],
});
await service.createUserClient('user1', config);
const callback = jest.fn();
service.startEventPolling('user1', callback, 100);
// 等待一小段时间让轮询执行
await new Promise(resolve => setTimeout(resolve, 50));
service.stopEventPolling('user1');
// 验证getEvents被调用
expect(mockZulipClientService.getEvents).toHaveBeenCalled();
});
it('应该在收到事件时调用回调', async () => {
const mockInstance = createMockClientInstance('user1', 'queue-123');
const mockEvents: any[] = [
{ id: 1, type: 'message', queue_id: 'queue-123', message: { content: 'Hello' } },
];
mockZulipClientService.createClient.mockResolvedValue(mockInstance);
mockZulipClientService.registerQueue.mockResolvedValue({
success: true,
queueId: 'queue-123',
lastEventId: 0,
});
mockZulipClientService.getEvents.mockResolvedValue({
success: true,
events: mockEvents,
});
await service.createUserClient('user1', config);
const callback = jest.fn();
service.startEventPolling('user1', callback, 100);
// 等待轮询执行
await new Promise(resolve => setTimeout(resolve, 150));
service.stopEventPolling('user1');
// 验证回调被调用
expect(callback).toHaveBeenCalledWith(mockEvents);
});
});
});

View File

@@ -1,655 +0,0 @@
/**
* Zulip客户端池服务
*
* 功能描述:
* - 为每个用户维护专用的Zulip客户端实例
* - 管理Zulip API Key和事件队列注册
* - 提供客户端获取、创建和销毁接口
*
* 主要方法:
* - createUserClient(): 为用户创建专用Zulip客户端
* - getUserClient(): 获取用户的Zulip客户端
* - registerEventQueue(): 注册事件队列
* - sendMessage(): 发送消息到指定Stream/Topic
* - destroyUserClient(): 注销事件队列并清理客户端
*
* 使用场景:
* - 用户登录时创建Zulip客户端
* - 消息发送时获取用户客户端
* - 用户登出时清理客户端资源
*
* 依赖模块:
* - ZulipClientService: Zulip客户端核心服务
* - AppLoggerService: 日志记录服务
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import {
ZulipClientService,
ZulipClientConfig,
ZulipClientInstance,
SendMessageResult,
RegisterQueueResult,
GetEventsResult,
} from './zulip-client.service';
/**
* 用户客户端信息接口
*/
export interface UserClientInfo {
userId: string;
clientInstance: ZulipClientInstance;
eventPollingActive: boolean;
eventCallback?: (events: any[]) => void;
}
/**
* 客户端池统计信息接口
*/
export interface PoolStats {
totalClients: number;
activeClients: number;
clientsWithQueues: number;
clientIds: string[];
}
@Injectable()
export class ZulipClientPoolService implements OnModuleDestroy {
private readonly clientPool = new Map<string, UserClientInfo>();
private readonly pollingIntervals = new Map<string, NodeJS.Timeout>();
private readonly logger = new Logger(ZulipClientPoolService.name);
constructor(
private readonly zulipClientService: ZulipClientService,
) {
this.logger.log('ZulipClientPoolService初始化完成');
}
/**
* 模块销毁时清理所有客户端
*/
async onModuleDestroy(): Promise<void> {
this.logger.log('ZulipClientPoolService模块销毁开始清理所有客户端', {
operation: 'onModuleDestroy',
clientCount: this.clientPool.size,
timestamp: new Date().toISOString(),
});
// 停止所有轮询
for (const [userId, interval] of this.pollingIntervals) {
clearInterval(interval);
this.logger.debug('停止用户事件轮询', { userId });
}
this.pollingIntervals.clear();
// 销毁所有客户端
const destroyPromises = Array.from(this.clientPool.keys()).map(userId =>
this.destroyUserClient(userId)
);
await Promise.allSettled(destroyPromises);
this.logger.log('ZulipClientPoolService清理完成', {
operation: 'onModuleDestroy',
timestamp: new Date().toISOString(),
});
}
/**
* 为用户创建专用Zulip客户端
*
* 功能描述:
* 使用用户的Zulip API Key创建客户端实例并注册事件队列
*
* 业务逻辑:
* 1. 检查是否已存在客户端
* 2. 验证API Key的有效性
* 3. 创建zulip-js客户端实例
* 4. 向Zulip服务器注册事件队列
* 5. 将客户端实例存储到池中
* 6. 返回客户端实例
*
* @param userId 用户ID
* @param config Zulip客户端配置
* @returns Promise<ZulipClientInstance> 创建的Zulip客户端实例
*
* @throws Error 当API Key无效或创建失败时
*/
async createUserClient(userId: string, config: ZulipClientConfig): Promise<ZulipClientInstance> {
const startTime = Date.now();
this.logger.log('开始创建用户Zulip客户端', {
operation: 'createUserClient',
userId,
realm: config.realm,
timestamp: new Date().toISOString(),
});
try {
// 1. 检查是否已存在客户端
const existingInfo = this.clientPool.get(userId);
if (existingInfo && existingInfo.clientInstance.isValid) {
this.logger.log('用户Zulip客户端已存在返回现有实例', {
operation: 'createUserClient',
userId,
queueId: existingInfo.clientInstance.queueId,
});
// 更新最后活动时间
existingInfo.clientInstance.lastActivity = new Date();
return existingInfo.clientInstance;
}
// 2. 创建新的客户端实例
const clientInstance = await this.zulipClientService.createClient(userId, config);
// 3. 注册事件队列
const registerResult = await this.zulipClientService.registerQueue(clientInstance);
if (!registerResult.success) {
throw new Error(`事件队列注册失败: ${registerResult.error}`);
}
// 4. 存储到客户端池
const userClientInfo: UserClientInfo = {
userId,
clientInstance,
eventPollingActive: false,
};
this.clientPool.set(userId, userClientInfo);
const duration = Date.now() - startTime;
this.logger.log('用户Zulip客户端创建成功', {
operation: 'createUserClient',
userId,
queueId: clientInstance.queueId,
duration,
timestamp: new Date().toISOString(),
});
return clientInstance;
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('创建用户Zulip客户端失败', {
operation: 'createUserClient',
userId,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
throw error;
}
}
/**
* 获取用户的Zulip客户端
*
* @param userId 用户ID
* @returns Promise<ZulipClientInstance | null> 用户的Zulip客户端实例不存在时返回null
*/
async getUserClient(userId: string): Promise<ZulipClientInstance | null> {
const userInfo = this.clientPool.get(userId);
if (userInfo && userInfo.clientInstance.isValid) {
// 更新最后活动时间
userInfo.clientInstance.lastActivity = new Date();
this.logger.debug('获取用户Zulip客户端', {
operation: 'getUserClient',
userId,
queueId: userInfo.clientInstance.queueId,
timestamp: new Date().toISOString(),
});
return userInfo.clientInstance;
}
this.logger.debug('用户Zulip客户端不存在或无效', {
operation: 'getUserClient',
userId,
exists: !!userInfo,
isValid: userInfo?.clientInstance.isValid,
timestamp: new Date().toISOString(),
});
return null;
}
/**
* 检查用户客户端是否存在
*
* @param userId 用户ID
* @returns boolean 客户端是否存在且有效
*/
hasUserClient(userId: string): boolean {
const userInfo = this.clientPool.get(userId);
return !!(userInfo && userInfo.clientInstance.isValid);
}
/**
* 注册事件队列
*
* 功能描述:
* 为用户的Zulip客户端注册事件队列
*
* @param userId 用户ID
* @returns Promise<RegisterQueueResult> 注册结果
*/
async registerEventQueue(userId: string): Promise<RegisterQueueResult> {
this.logger.log('注册用户Zulip事件队列', {
operation: 'registerEventQueue',
userId,
timestamp: new Date().toISOString(),
});
try {
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
return {
success: false,
error: '用户Zulip客户端不存在或无效',
};
}
// 如果已有队列,先注销
if (userInfo.clientInstance.queueId) {
await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
}
// 注册新队列
const result = await this.zulipClientService.registerQueue(userInfo.clientInstance);
this.logger.log('用户事件队列注册完成', {
operation: 'registerEventQueue',
userId,
success: result.success,
queueId: result.queueId,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
this.logger.error('注册用户事件队列失败', {
operation: 'registerEventQueue',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: err.message,
};
}
}
/**
* 注销事件队列
*
* @param userId 用户ID
* @returns Promise<boolean> 是否成功注销
*/
async deregisterEventQueue(userId: string): Promise<boolean> {
this.logger.log('注销用户Zulip事件队列', {
operation: 'deregisterEventQueue',
userId,
timestamp: new Date().toISOString(),
});
try {
const userInfo = this.clientPool.get(userId);
if (!userInfo) {
this.logger.log('用户客户端不存在,跳过注销', {
operation: 'deregisterEventQueue',
userId,
});
return true;
}
// 停止事件轮询
this.stopEventPolling(userId);
// 注销队列
const result = await this.zulipClientService.deregisterQueue(userInfo.clientInstance);
this.logger.log('用户事件队列注销完成', {
operation: 'deregisterEventQueue',
userId,
success: result,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
this.logger.error('注销用户事件队列失败', {
operation: 'deregisterEventQueue',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return false;
}
}
/**
* 发送消息到指定Stream/Topic
*
* 功能描述:
* 使用用户的Zulip客户端发送消息到指定的Stream和Topic
*
* @param userId 用户ID
* @param stream 目标Stream名称
* @param topic 目标Topic名称
* @param content 消息内容
* @returns Promise<SendMessageResult> 发送结果
*/
async sendMessage(
userId: string,
stream: string,
topic: string,
content: string
): Promise<SendMessageResult> {
this.logger.log('发送消息到Zulip', {
operation: 'sendMessage',
userId,
stream,
topic,
contentLength: content.length,
timestamp: new Date().toISOString(),
});
try {
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
return {
success: false,
error: '用户Zulip客户端不存在或无效',
};
}
const result = await this.zulipClientService.sendMessage(
userInfo.clientInstance,
stream,
topic,
content
);
this.logger.log('消息发送完成', {
operation: 'sendMessage',
userId,
stream,
topic,
success: result.success,
messageId: result.messageId,
timestamp: new Date().toISOString(),
});
return result;
} catch (error) {
const err = error as Error;
this.logger.error('发送消息失败', {
operation: 'sendMessage',
userId,
stream,
topic,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: err.message,
};
}
}
/**
* 开始事件轮询
*
* 功能描述:
* 启动异步监听器轮询Zulip事件队列获取新消息
*
* @param userId 用户ID
* @param callback 事件处理回调函数
* @param intervalMs 轮询间隔毫秒默认5000ms
*/
startEventPolling(
userId: string,
callback: (events: any[]) => void,
intervalMs: number = 5000
): void {
this.logger.log('开始用户事件轮询', {
operation: 'startEventPolling',
userId,
intervalMs,
timestamp: new Date().toISOString(),
});
const userInfo = this.clientPool.get(userId);
if (!userInfo || !userInfo.clientInstance.isValid) {
this.logger.warn('无法启动事件轮询:客户端不存在或无效', {
operation: 'startEventPolling',
userId,
});
return;
}
// 停止现有轮询
this.stopEventPolling(userId);
// 保存回调
userInfo.eventCallback = callback;
userInfo.eventPollingActive = true;
// 启动轮询
const pollEvents = async () => {
if (!userInfo.eventPollingActive) {
return;
}
try {
const result = await this.zulipClientService.getEvents(
userInfo.clientInstance,
true // 不阻塞
);
if (result.success && result.events && result.events.length > 0) {
this.logger.debug('收到Zulip事件', {
operation: 'pollEvents',
userId,
eventCount: result.events.length,
});
if (userInfo.eventCallback) {
userInfo.eventCallback(result.events);
}
}
} catch (error) {
const err = error as Error;
this.logger.error('事件轮询异常', {
operation: 'pollEvents',
userId,
error: err.message,
});
}
};
// 立即执行一次
pollEvents();
// 设置定时轮询
const interval = setInterval(pollEvents, intervalMs);
this.pollingIntervals.set(userId, interval);
this.logger.log('用户事件轮询已启动', {
operation: 'startEventPolling',
userId,
timestamp: new Date().toISOString(),
});
}
/**
* 停止事件轮询
*
* @param userId 用户ID
*/
stopEventPolling(userId: string): void {
const interval = this.pollingIntervals.get(userId);
if (interval) {
clearInterval(interval);
this.pollingIntervals.delete(userId);
this.logger.log('用户事件轮询已停止', {
operation: 'stopEventPolling',
userId,
timestamp: new Date().toISOString(),
});
}
const userInfo = this.clientPool.get(userId);
if (userInfo) {
userInfo.eventPollingActive = false;
userInfo.eventCallback = undefined;
}
}
/**
* 注销事件队列并清理客户端
*
* 功能描述:
* 注销用户的Zulip事件队列清理客户端实例和相关资源
*
* @param userId 用户ID
* @returns Promise<void>
*/
async destroyUserClient(userId: string): Promise<void> {
this.logger.log('开始销毁用户Zulip客户端', {
operation: 'destroyUserClient',
userId,
timestamp: new Date().toISOString(),
});
try {
// 1. 停止事件轮询
this.stopEventPolling(userId);
// 2. 获取客户端信息
const userInfo = this.clientPool.get(userId);
if (!userInfo) {
this.logger.log('用户Zulip客户端不存在跳过销毁', {
operation: 'destroyUserClient',
userId,
});
return;
}
// 3. 销毁客户端实例
await this.zulipClientService.destroyClient(userInfo.clientInstance);
// 4. 从池中移除
this.clientPool.delete(userId);
this.logger.log('用户Zulip客户端销毁成功', {
operation: 'destroyUserClient',
userId,
timestamp: new Date().toISOString(),
});
} catch (error) {
const err = error as Error;
this.logger.error('销毁用户Zulip客户端失败', {
operation: 'destroyUserClient',
userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
// 即使销毁失败也要从池中移除
this.clientPool.delete(userId);
}
}
/**
* 获取客户端池统计信息
*
* @returns PoolStats 客户端池统计信息
*/
getPoolStats(): PoolStats {
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const clients = Array.from(this.clientPool.values());
const activeClients = clients.filter(
info => info.clientInstance.lastActivity > fiveMinutesAgo
);
const clientsWithQueues = clients.filter(
info => info.clientInstance.queueId !== undefined
);
return {
totalClients: this.clientPool.size,
activeClients: activeClients.length,
clientsWithQueues: clientsWithQueues.length,
clientIds: Array.from(this.clientPool.keys()),
};
}
/**
* 清理过期客户端
*
* 功能描述:
* 清理超过指定时间未活动的客户端
*
* @param maxIdleMinutes 最大空闲时间分钟默认30分钟
* @returns Promise<number> 清理的客户端数量
*/
async cleanupIdleClients(maxIdleMinutes: number = 30): Promise<number> {
this.logger.log('开始清理过期客户端', {
operation: 'cleanupIdleClients',
maxIdleMinutes,
totalClients: this.clientPool.size,
timestamp: new Date().toISOString(),
});
const now = new Date();
const cutoffTime = new Date(now.getTime() - maxIdleMinutes * 60 * 1000);
const expiredUserIds: string[] = [];
for (const [userId, userInfo] of this.clientPool) {
if (userInfo.clientInstance.lastActivity < cutoffTime) {
expiredUserIds.push(userId);
}
}
// 销毁过期客户端
for (const userId of expiredUserIds) {
await this.destroyUserClient(userId);
}
this.logger.log('过期客户端清理完成', {
operation: 'cleanupIdleClients',
cleanedCount: expiredUserIds.length,
remainingClients: this.clientPool.size,
timestamp: new Date().toISOString(),
});
return expiredUserIds.length;
}
}

View File

@@ -1,410 +0,0 @@
/**
* Zulip客户端核心服务测试
*
* 功能描述:
* - 测试ZulipClientService的核心功能
* - 包含属性测试验证客户端生命周期管理
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import { ZulipClientService, ZulipClientConfig, ZulipClientInstance } from './zulip-client.service';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
describe('ZulipClientService', () => {
let service: ZulipClientService;
let mockLogger: jest.Mocked<AppLoggerService>;
// Mock zulip-js模块
const mockZulipClient = {
users: {
me: {
getProfile: jest.fn(),
},
},
messages: {
send: jest.fn(),
},
queues: {
register: jest.fn(),
deregister: jest.fn(),
},
events: {
retrieve: jest.fn(),
},
};
const mockZulipInit = jest.fn().mockResolvedValue(mockZulipClient);
beforeEach(async () => {
// 重置所有mock
jest.clearAllMocks();
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
ZulipClientService,
{
provide: AppLoggerService,
useValue: mockLogger,
},
],
}).compile();
service = module.get<ZulipClientService>(ZulipClientService);
// Mock动态导入
jest.spyOn(service as any, 'loadZulipModule').mockResolvedValue(mockZulipInit);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('配置验证', () => {
it('应该拒绝空的username', async () => {
const config: ZulipClientConfig = {
username: '',
apiKey: 'valid-api-key',
realm: 'https://zulip.example.com',
};
await expect(service.createClient('user1', config)).rejects.toThrow('无效的username配置');
});
it('应该拒绝空的apiKey', async () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: '',
realm: 'https://zulip.example.com',
};
await expect(service.createClient('user1', config)).rejects.toThrow('无效的apiKey配置');
});
it('应该拒绝无效的realm URL', async () => {
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'valid-api-key',
realm: 'not-a-valid-url',
};
await expect(service.createClient('user1', config)).rejects.toThrow('realm必须是有效的URL');
});
});
describe('客户端创建', () => {
it('应该成功创建客户端', async () => {
mockZulipClient.users.me.getProfile.mockResolvedValue({
result: 'success',
email: 'user@example.com',
});
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'valid-api-key',
realm: 'https://zulip.example.com',
};
const client = await service.createClient('user1', config);
expect(client).toBeDefined();
expect(client.userId).toBe('user1');
expect(client.isValid).toBe(true);
expect(client.config).toEqual(config);
});
it('应该在API Key验证失败时抛出错误', async () => {
mockZulipClient.users.me.getProfile.mockResolvedValue({
result: 'error',
msg: 'Invalid API key',
});
const config: ZulipClientConfig = {
username: 'user@example.com',
apiKey: 'invalid-api-key',
realm: 'https://zulip.example.com',
};
await expect(service.createClient('user1', config)).rejects.toThrow('API Key验证失败');
});
});
describe('消息发送', () => {
let clientInstance: ZulipClientInstance;
beforeEach(() => {
clientInstance = {
userId: 'user1',
config: {
username: 'user@example.com',
apiKey: 'valid-api-key',
realm: 'https://zulip.example.com',
},
client: mockZulipClient,
lastEventId: -1,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
};
});
it('应该成功发送消息', async () => {
mockZulipClient.messages.send.mockResolvedValue({
result: 'success',
id: 12345,
});
const result = await service.sendMessage(clientInstance, 'test-stream', 'test-topic', 'Hello World');
expect(result.success).toBe(true);
expect(result.messageId).toBe(12345);
});
it('应该在客户端无效时返回错误', async () => {
clientInstance.isValid = false;
const result = await service.sendMessage(clientInstance, 'test-stream', 'test-topic', 'Hello World');
expect(result.success).toBe(false);
expect(result.error).toContain('无效');
});
});
describe('事件队列管理', () => {
let clientInstance: ZulipClientInstance;
beforeEach(() => {
clientInstance = {
userId: 'user1',
config: {
username: 'user@example.com',
apiKey: 'valid-api-key',
realm: 'https://zulip.example.com',
},
client: mockZulipClient,
lastEventId: -1,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
};
});
it('应该成功注册事件队列', async () => {
mockZulipClient.queues.register.mockResolvedValue({
result: 'success',
queue_id: 'queue-123',
last_event_id: 0,
});
const result = await service.registerQueue(clientInstance);
expect(result.success).toBe(true);
expect(result.queueId).toBe('queue-123');
expect(clientInstance.queueId).toBe('queue-123');
});
it('应该成功注销事件队列', async () => {
clientInstance.queueId = 'queue-123';
mockZulipClient.queues.deregister.mockResolvedValue({
result: 'success',
});
const result = await service.deregisterQueue(clientInstance);
expect(result).toBe(true);
expect(clientInstance.queueId).toBeUndefined();
});
});
/**
* 属性测试: Zulip客户端生命周期管理
*
* **Feature: zulip-integration, Property 2: Zulip客户端生命周期管理**
* **Validates: Requirements 2.1, 2.2, 2.5**
*
* 对于任何用户的Zulip API Key系统应该创建专用的Zulip客户端实例
* 注册事件队列,并在用户登出时完全清理客户端和队列资源
*/
describe('Property 2: Zulip客户端生命周期管理', () => {
/**
* 属性: 对于任何有效的配置,创建客户端后应该处于有效状态
*/
it('对于任何有效配置,创建的客户端应该处于有效状态', async () => {
mockZulipClient.users.me.getProfile.mockResolvedValue({
result: 'success',
email: 'user@example.com',
});
await fc.assert(
fc.asyncProperty(
// 生成有效的用户ID
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的邮箱格式
fc.emailAddress(),
// 生成有效的API Key
fc.string({ minLength: 10, maxLength: 100 }).filter(s => s.trim().length >= 10),
async (userId, email, apiKey) => {
const config: ZulipClientConfig = {
username: email,
apiKey: apiKey,
realm: 'https://zulip.example.com',
};
const client = await service.createClient(userId, config);
// 验证客户端状态
expect(client.userId).toBe(userId);
expect(client.isValid).toBe(true);
expect(client.config.username).toBe(email);
expect(client.config.apiKey).toBe(apiKey);
expect(client.createdAt).toBeInstanceOf(Date);
expect(client.lastActivity).toBeInstanceOf(Date);
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 对于任何客户端注册队列后应该有有效的队列ID
*/
it('对于任何客户端注册队列后应该有有效的队列ID', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 5, maxLength: 50 }).filter(s => s.trim().length >= 5),
fc.integer({ min: 0, max: 1000 }),
async (userId, queueId, lastEventId) => {
mockZulipClient.queues.register.mockResolvedValue({
result: 'success',
queue_id: queueId,
last_event_id: lastEventId,
});
const clientInstance: ZulipClientInstance = {
userId,
config: {
username: 'user@example.com',
apiKey: 'valid-api-key',
realm: 'https://zulip.example.com',
},
client: mockZulipClient,
lastEventId: -1,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
};
const result = await service.registerQueue(clientInstance);
// 验证队列注册结果
expect(result.success).toBe(true);
expect(result.queueId).toBe(queueId);
expect(clientInstance.queueId).toBe(queueId);
expect(clientInstance.lastEventId).toBe(lastEventId);
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 对于任何客户端,销毁后应该处于无效状态且队列被清理
*/
it('对于任何客户端,销毁后应该处于无效状态且队列被清理', async () => {
mockZulipClient.queues.deregister.mockResolvedValue({
result: 'success',
});
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 5, maxLength: 50 }).filter(s => s.trim().length >= 5),
async (userId, queueId) => {
const clientInstance: ZulipClientInstance = {
userId,
config: {
username: 'user@example.com',
apiKey: 'valid-api-key',
realm: 'https://zulip.example.com',
},
client: mockZulipClient,
queueId: queueId,
lastEventId: 10,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
};
await service.destroyClient(clientInstance);
// 验证客户端被正确销毁
expect(clientInstance.isValid).toBe(false);
expect(clientInstance.queueId).toBeUndefined();
expect(clientInstance.client).toBeNull();
}
),
{ numRuns: 100 }
);
}, 30000);
/**
* 属性: 创建-注册-销毁的完整生命周期应该正确管理资源
*/
it('创建-注册-销毁的完整生命周期应该正确管理资源', async () => {
mockZulipClient.users.me.getProfile.mockResolvedValue({
result: 'success',
email: 'user@example.com',
});
mockZulipClient.queues.register.mockResolvedValue({
result: 'success',
queue_id: 'queue-123',
last_event_id: 0,
});
mockZulipClient.queues.deregister.mockResolvedValue({
result: 'success',
});
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
fc.emailAddress(),
fc.string({ minLength: 10, maxLength: 100 }).filter(s => s.trim().length >= 10),
async (userId, email, apiKey) => {
const config: ZulipClientConfig = {
username: email,
apiKey: apiKey,
realm: 'https://zulip.example.com',
};
// 1. 创建客户端
const client = await service.createClient(userId, config);
expect(client.isValid).toBe(true);
// 2. 注册事件队列
const registerResult = await service.registerQueue(client);
expect(registerResult.success).toBe(true);
expect(client.queueId).toBeDefined();
// 3. 销毁客户端
await service.destroyClient(client);
expect(client.isValid).toBe(false);
expect(client.queueId).toBeUndefined();
}
),
{ numRuns: 100 }
);
}, 30000);
});
});

View File

@@ -1,704 +0,0 @@
/**
* Zulip客户端核心服务
*
* 功能描述:
* - 封装Zulip REST API调用
* - 实现API Key验证和错误处理
* - 提供消息发送、事件队列管理等核心功能
*
* 主要方法:
* - initialize(): 初始化Zulip客户端并验证API Key
* - sendMessage(): 发送消息到指定Stream/Topic
* - registerQueue(): 注册事件队列
* - deregisterQueue(): 注销事件队列
* - getEvents(): 获取事件队列中的事件
*
* 使用场景:
* - 用户登录时创建和验证Zulip客户端
* - 消息发送和接收
* - 事件队列管理
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Injectable, Logger } from '@nestjs/common';
import { ZulipAPI, Internal, Enums } from '../interfaces/zulip.interfaces';
/**
* Zulip客户端配置接口
*/
export interface ZulipClientConfig {
username: string;
apiKey: string;
realm: string;
}
/**
* Zulip客户端实例接口
*/
export interface ZulipClientInstance {
userId: string;
config: ZulipClientConfig;
client: any; // zulip-js客户端实例
queueId?: string;
lastEventId: number;
createdAt: Date;
lastActivity: Date;
isValid: boolean;
}
/**
* 发送消息结果接口
*/
export interface SendMessageResult {
success: boolean;
messageId?: number;
error?: string;
}
/**
* 事件队列注册结果接口
*/
export interface RegisterQueueResult {
success: boolean;
queueId?: string;
lastEventId?: number;
error?: string;
}
/**
* 获取事件结果接口
*/
export interface GetEventsResult {
success: boolean;
events?: ZulipAPI.Event[];
error?: string;
}
@Injectable()
export class ZulipClientService {
private readonly logger = new Logger(ZulipClientService.name);
constructor() {
this.logger.log('ZulipClientService初始化完成');
}
/**
* 创建并初始化Zulip客户端
*
* 功能描述:
* 使用提供的配置创建zulip-js客户端实例并验证API Key的有效性
*
* 业务逻辑:
* 1. 验证配置参数的完整性
* 2. 创建zulip-js客户端实例
* 3. 调用API验证凭证有效性
* 4. 返回初始化后的客户端实例
*
* @param userId 用户ID
* @param config Zulip客户端配置
* @returns Promise<ZulipClientInstance> 初始化后的客户端实例
*
* @throws Error 当配置无效或API Key验证失败时
*/
async createClient(userId: string, config: ZulipClientConfig): Promise<ZulipClientInstance> {
const startTime = Date.now();
this.logger.log('开始创建Zulip客户端', {
operation: 'createClient',
userId,
realm: config.realm,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证配置参数
this.validateConfig(config);
// 2. 动态导入zulip-js
const zulipInit = await this.loadZulipModule();
// 3. 创建zulip-js客户端实例
const client = await zulipInit({
username: config.username,
apiKey: config.apiKey,
realm: config.realm,
});
// 4. 验证API Key有效性 - 通过获取用户信息
const profile = await client.users.me.getProfile();
if (profile.result !== 'success') {
throw new Error(`API Key验证失败: ${profile.msg || '未知错误'}`);
}
const clientInstance: ZulipClientInstance = {
userId,
config,
client,
lastEventId: -1,
createdAt: new Date(),
lastActivity: new Date(),
isValid: true,
};
const duration = Date.now() - startTime;
this.logger.log('Zulip客户端创建成功', {
operation: 'createClient',
userId,
realm: config.realm,
userEmail: profile.email,
duration,
timestamp: new Date().toISOString(),
});
return clientInstance;
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('创建Zulip客户端失败', {
operation: 'createClient',
userId,
realm: config.realm,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
throw new Error(`创建Zulip客户端失败: ${err.message}`);
}
}
/**
* 验证API Key有效性
*
* 功能描述:
* 通过调用Zulip API验证API Key是否有效
*
* @param clientInstance Zulip客户端实例
* @returns Promise<boolean> API Key是否有效
*/
async validateApiKey(clientInstance: ZulipClientInstance): Promise<boolean> {
this.logger.log('验证API Key有效性', {
operation: 'validateApiKey',
userId: clientInstance.userId,
timestamp: new Date().toISOString(),
});
try {
const profile = await clientInstance.client.users.me.getProfile();
const isValid = profile.result === 'success';
clientInstance.isValid = isValid;
clientInstance.lastActivity = new Date();
this.logger.log('API Key验证完成', {
operation: 'validateApiKey',
userId: clientInstance.userId,
isValid,
timestamp: new Date().toISOString(),
});
return isValid;
} catch (error) {
const err = error as Error;
this.logger.error('API Key验证失败', {
operation: 'validateApiKey',
userId: clientInstance.userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
clientInstance.isValid = false;
return false;
}
}
/**
* 发送消息到指定Stream/Topic
*
* 功能描述:
* 使用Zulip客户端发送消息到指定的Stream和Topic
*
* 业务逻辑:
* 1. 验证客户端实例有效性
* 2. 构建消息请求参数
* 3. 调用Zulip API发送消息
* 4. 处理响应并返回结果
*
* @param clientInstance Zulip客户端实例
* @param stream 目标Stream名称
* @param topic 目标Topic名称
* @param content 消息内容
* @returns Promise<SendMessageResult> 发送结果
*/
async sendMessage(
clientInstance: ZulipClientInstance,
stream: string,
topic: string,
content: string,
): Promise<SendMessageResult> {
const startTime = Date.now();
this.logger.log('发送消息到Zulip', {
operation: 'sendMessage',
userId: clientInstance.userId,
stream,
topic,
contentLength: content.length,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证客户端有效性
if (!clientInstance.isValid) {
throw new Error('Zulip客户端无效');
}
// 2. 构建消息参数
const params = {
type: 'stream',
to: stream,
subject: topic,
content: content,
};
// 3. 发送消息
const response = await clientInstance.client.messages.send(params);
// 4. 更新最后活动时间
clientInstance.lastActivity = new Date();
const duration = Date.now() - startTime;
if (response.result === 'success') {
this.logger.log('消息发送成功', {
operation: 'sendMessage',
userId: clientInstance.userId,
stream,
topic,
messageId: response.id,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
messageId: response.id,
};
} else {
this.logger.warn('消息发送失败', {
operation: 'sendMessage',
userId: clientInstance.userId,
stream,
topic,
error: response.msg,
duration,
timestamp: new Date().toISOString(),
});
return {
success: false,
error: response.msg || '消息发送失败',
};
}
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('发送消息异常', {
operation: 'sendMessage',
userId: clientInstance.userId,
stream,
topic,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: err.message,
};
}
}
/**
* 注册事件队列
*
* 功能描述:
* 向Zulip服务器注册事件队列用于接收消息通知
*
* 业务逻辑:
* 1. 验证客户端实例有效性
* 2. 构建队列注册参数
* 3. 调用Zulip API注册队列
* 4. 保存队列ID到客户端实例
*
* @param clientInstance Zulip客户端实例
* @param eventTypes 要订阅的事件类型列表
* @returns Promise<RegisterQueueResult> 注册结果
*/
async registerQueue(
clientInstance: ZulipClientInstance,
eventTypes: string[] = ['message'],
): Promise<RegisterQueueResult> {
const startTime = Date.now();
this.logger.log('注册Zulip事件队列', {
operation: 'registerQueue',
userId: clientInstance.userId,
eventTypes,
timestamp: new Date().toISOString(),
});
try {
// 1. 验证客户端有效性
if (!clientInstance.isValid) {
throw new Error('Zulip客户端无效');
}
// 2. 构建注册参数
const params = {
event_types: eventTypes,
};
// 3. 注册队列
const response = await clientInstance.client.queues.register(params);
const duration = Date.now() - startTime;
if (response.result === 'success') {
// 4. 保存队列信息
clientInstance.queueId = response.queue_id;
clientInstance.lastEventId = response.last_event_id;
clientInstance.lastActivity = new Date();
this.logger.log('事件队列注册成功', {
operation: 'registerQueue',
userId: clientInstance.userId,
queueId: response.queue_id,
lastEventId: response.last_event_id,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
queueId: response.queue_id,
lastEventId: response.last_event_id,
};
} else {
this.logger.warn('事件队列注册失败', {
operation: 'registerQueue',
userId: clientInstance.userId,
error: response.msg,
duration,
timestamp: new Date().toISOString(),
});
return {
success: false,
error: response.msg || '事件队列注册失败',
};
}
} catch (error) {
const err = error as Error;
const duration = Date.now() - startTime;
this.logger.error('注册事件队列异常', {
operation: 'registerQueue',
userId: clientInstance.userId,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: err.message,
};
}
}
/**
* 注销事件队列
*
* 功能描述:
* 注销已注册的Zulip事件队列
*
* @param clientInstance Zulip客户端实例
* @returns Promise<boolean> 是否成功注销
*/
async deregisterQueue(clientInstance: ZulipClientInstance): Promise<boolean> {
this.logger.log('注销Zulip事件队列', {
operation: 'deregisterQueue',
userId: clientInstance.userId,
queueId: clientInstance.queueId,
timestamp: new Date().toISOString(),
});
try {
if (!clientInstance.queueId) {
this.logger.log('无事件队列需要注销', {
operation: 'deregisterQueue',
userId: clientInstance.userId,
});
return true;
}
const response = await clientInstance.client.queues.deregister({
queue_id: clientInstance.queueId,
});
if (response.result === 'success') {
clientInstance.queueId = undefined;
clientInstance.lastEventId = -1;
this.logger.log('事件队列注销成功', {
operation: 'deregisterQueue',
userId: clientInstance.userId,
timestamp: new Date().toISOString(),
});
return true;
} else {
this.logger.warn('事件队列注销失败', {
operation: 'deregisterQueue',
userId: clientInstance.userId,
error: response.msg,
timestamp: new Date().toISOString(),
});
return false;
}
} catch (error) {
const err = error as Error;
// 如果是JSON解析错误说明队列可能已经过期或被删除这是正常的
if (err.message.includes('invalid json response') || err.message.includes('Unexpected token')) {
this.logger.debug('事件队列可能已过期,跳过注销', {
operation: 'deregisterQueue',
userId: clientInstance.userId,
queueId: clientInstance.queueId,
});
// 清理本地状态
clientInstance.queueId = undefined;
clientInstance.lastEventId = -1;
return true;
}
this.logger.error('注销事件队列异常', {
operation: 'deregisterQueue',
userId: clientInstance.userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
// 即使注销失败,也清理本地状态
clientInstance.queueId = undefined;
clientInstance.lastEventId = -1;
return false;
}
}
/**
* 获取事件队列中的事件
*
* 功能描述:
* 从Zulip事件队列中获取新事件
*
* @param clientInstance Zulip客户端实例
* @param dontBlock 是否不阻塞等待新事件
* @returns Promise<GetEventsResult> 获取结果
*/
async getEvents(
clientInstance: ZulipClientInstance,
dontBlock: boolean = false,
): Promise<GetEventsResult> {
this.logger.debug('获取Zulip事件', {
operation: 'getEvents',
userId: clientInstance.userId,
queueId: clientInstance.queueId,
lastEventId: clientInstance.lastEventId,
dontBlock,
timestamp: new Date().toISOString(),
});
try {
if (!clientInstance.queueId) {
throw new Error('事件队列未注册');
}
const params = {
queue_id: clientInstance.queueId,
last_event_id: clientInstance.lastEventId,
dont_block: dontBlock,
};
const response = await clientInstance.client.events.retrieve(params);
if (response.result === 'success') {
// 更新最后事件ID
if (response.events && response.events.length > 0) {
const lastEvent = response.events[response.events.length - 1];
clientInstance.lastEventId = lastEvent.id;
}
clientInstance.lastActivity = new Date();
this.logger.debug('获取事件成功', {
operation: 'getEvents',
userId: clientInstance.userId,
eventCount: response.events?.length || 0,
timestamp: new Date().toISOString(),
});
return {
success: true,
events: response.events || [],
};
} else {
this.logger.warn('获取事件失败', {
operation: 'getEvents',
userId: clientInstance.userId,
error: response.msg,
timestamp: new Date().toISOString(),
});
return {
success: false,
error: response.msg || '获取事件失败',
};
}
} catch (error) {
const err = error as Error;
this.logger.error('获取事件异常', {
operation: 'getEvents',
userId: clientInstance.userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
error: err.message,
};
}
}
/**
* 销毁客户端实例
*
* 功能描述:
* 清理客户端资源,注销事件队列
*
* @param clientInstance Zulip客户端实例
* @returns Promise<void>
*/
async destroyClient(clientInstance: ZulipClientInstance): Promise<void> {
this.logger.log('销毁Zulip客户端', {
operation: 'destroyClient',
userId: clientInstance.userId,
queueId: clientInstance.queueId,
timestamp: new Date().toISOString(),
});
try {
// 注销事件队列
if (clientInstance.queueId) {
await this.deregisterQueue(clientInstance);
}
// 标记客户端为无效
clientInstance.isValid = false;
clientInstance.client = null;
this.logger.log('Zulip客户端销毁完成', {
operation: 'destroyClient',
userId: clientInstance.userId,
timestamp: new Date().toISOString(),
});
} catch (error) {
const err = error as Error;
this.logger.error('销毁Zulip客户端异常', {
operation: 'destroyClient',
userId: clientInstance.userId,
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
// 即使出错也标记为无效
clientInstance.isValid = false;
clientInstance.client = null;
}
}
/**
* 验证配置参数
*
* @param config Zulip客户端配置
* @throws Error 当配置无效时
* @private
*/
private validateConfig(config: ZulipClientConfig): void {
if (!config.username || typeof config.username !== 'string') {
throw new Error('无效的username配置');
}
if (!config.apiKey || typeof config.apiKey !== 'string') {
throw new Error('无效的apiKey配置');
}
if (!config.realm || typeof config.realm !== 'string') {
throw new Error('无效的realm配置');
}
// 验证realm是否为有效URL
try {
new URL(config.realm);
} catch {
throw new Error('realm必须是有效的URL');
}
}
/**
* 动态加载zulip-js模块
*
* @returns Promise<any> zulip-js初始化函数
* @private
*/
private async loadZulipModule(): Promise<any> {
try {
// 使用动态导入加载zulip-js
const zulipModule = await import('zulip-js');
return zulipModule.default || zulipModule;
} catch (error) {
const err = error as Error;
this.logger.error('加载zulip-js模块失败', {
operation: 'loadZulipModule',
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
throw new Error(`加载zulip-js模块失败: ${err.message}`);
}
}
}

View File

@@ -24,18 +24,17 @@ import {
ZulipMessage,
GameMessage,
MessageDistributor,
} from './zulip-event-processor.service';
import { SessionManagerService, GameSession } from './session-manager.service';
import { ConfigManagerService } from './config-manager.service';
import { ZulipClientPoolService } from './zulip-client-pool.service';
} from './zulip_event_processor.service';
import { SessionManagerService, GameSession } from './session_manager.service';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
describe('ZulipEventProcessorService', () => {
let service: ZulipEventProcessorService;
let mockLogger: jest.Mocked<AppLoggerService>;
let mockSessionManager: jest.Mocked<SessionManagerService>;
let mockConfigManager: jest.Mocked<ConfigManagerService>;
let mockClientPool: jest.Mocked<ZulipClientPoolService>;
let mockConfigManager: jest.Mocked<IZulipConfigService>;
let mockClientPool: jest.Mocked<IZulipClientPoolService>;
let mockDistributor: jest.Mocked<MessageDistributor>;
// 创建模拟Zulip消息
@@ -87,14 +86,26 @@ describe('ZulipEventProcessorService', () => {
mockConfigManager = {
getMapIdByStream: jest.fn(),
getStreamByMap: jest.fn(),
getMapConfig: jest.fn(),
getTopicByObject: jest.fn(),
getZulipConfig: jest.fn(),
hasMap: jest.fn(),
hasStream: jest.fn(),
getAllMapIds: jest.fn(),
getAllStreams: jest.fn(),
reloadConfig: jest.fn(),
validateConfig: jest.fn(),
} as any;
mockClientPool = {
getUserClient: jest.fn(),
createUserClient: jest.fn(),
destroyUserClient: jest.fn(),
hasUserClient: jest.fn(),
sendMessage: jest.fn(),
registerEventQueue: jest.fn(),
deregisterEventQueue: jest.fn(),
getPoolStats: jest.fn(),
cleanupIdleClients: jest.fn(),
} as any;
mockDistributor = {
@@ -114,11 +125,11 @@ describe('ZulipEventProcessorService', () => {
useValue: mockSessionManager,
},
{
provide: ConfigManagerService,
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigManager,
},
{
provide: ZulipClientPoolService,
provide: 'ZULIP_CLIENT_POOL_SERVICE',
useValue: mockClientPool,
},
],

View File

@@ -31,9 +31,8 @@
*/
import { Injectable, OnModuleDestroy, Inject, forwardRef, Logger } from '@nestjs/common';
import { SessionManagerService } from './session-manager.service';
import { ConfigManagerService } from './config-manager.service';
import { ZulipClientPoolService } from './zulip-client-pool.service';
import { SessionManagerService } from './session_manager.service';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
/**
* Zulip消息接口
@@ -94,6 +93,28 @@ export interface EventProcessingStats {
lastEventTime?: Date;
}
/**
* Zulip事件处理服务类
*
*
* - Zulip接收的事件队列消息
* - Zulip消息转换为游戏协议格式
* -
* -
*
*
* - processEvents(): Zulip事件队列
* - processMessage():
* - startProcessing():
* - stopProcessing():
* - registerQueue():
*
* 使
* - Zulip服务器推送的消息
* - Zulip消息转发给游戏客户端
* -
* -
*/
@Injectable()
export class ZulipEventProcessorService implements OnModuleDestroy {
private readonly logger = new Logger(ZulipEventProcessorService.name);
@@ -109,9 +130,10 @@ export class ZulipEventProcessorService implements OnModuleDestroy {
constructor(
private readonly sessionManager: SessionManagerService,
private readonly configManager: ConfigManagerService,
@Inject(forwardRef(() => ZulipClientPoolService))
private readonly clientPool: ZulipClientPoolService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly clientPool: IZulipClientPoolService,
) {
this.logger.log('ZulipEventProcessorService初始化完成');
}

View File

@@ -1,194 +0,0 @@
/**
* zulip-js类型声明文件
*
* 功能描述:
* - 为zulip-js库提供TypeScript类型定义
* - 支持IDE代码提示和类型检查
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
declare module 'zulip-js' {
/**
* Zulip配置接口
*/
interface ZulipConfig {
username?: string;
apiKey?: string;
password?: string;
realm?: string;
zuliprc?: string;
}
/**
* Zulip API响应基础接口
*/
interface ZulipResponse {
result: 'success' | 'error';
msg?: string;
}
/**
* 用户信息接口
*/
interface UserProfile extends ZulipResponse {
email?: string;
user_id?: number;
full_name?: string;
is_admin?: boolean;
is_bot?: boolean;
}
/**
* 消息发送响应接口
*/
interface SendMessageResponse extends ZulipResponse {
id?: number;
}
/**
* 队列注册响应接口
*/
interface RegisterQueueResponse extends ZulipResponse {
queue_id?: string;
last_event_id?: number;
}
/**
* 事件接口
*/
interface ZulipEvent {
id: number;
type: string;
message?: {
id: number;
sender_email: string;
sender_full_name: string;
content: string;
stream_id: number;
subject: string;
timestamp: number;
};
}
/**
* 获取事件响应接口
*/
interface GetEventsResponse extends ZulipResponse {
events?: ZulipEvent[];
}
/**
* 消息发送参数接口
*/
interface SendMessageParams {
type: 'stream' | 'private';
to: string | string[];
subject?: string;
content: string;
}
/**
* 队列注册参数接口
*/
interface RegisterQueueParams {
event_types?: string[];
narrow?: Array<[string, string]>;
all_public_streams?: boolean;
}
/**
* 获取事件参数接口
*/
interface GetEventsParams {
queue_id: string;
last_event_id: number;
dont_block?: boolean;
}
/**
* 注销队列参数接口
*/
interface DeregisterQueueParams {
queue_id: string;
}
/**
* Zulip客户端接口
*/
interface ZulipClient {
users: {
me: {
getProfile(): Promise<UserProfile>;
pointer: {
retrieve(): Promise<ZulipResponse & { pointer?: number }>;
update(params: { pointer: number }): Promise<ZulipResponse>;
};
subscriptions(): Promise<ZulipResponse>;
};
retrieve(): Promise<ZulipResponse & { members?: any[] }>;
create(params: any): Promise<ZulipResponse>;
};
messages: {
send(params: SendMessageParams): Promise<SendMessageResponse>;
retrieve(params: any): Promise<ZulipResponse & { messages?: any[] }>;
render(params: { content: string }): Promise<ZulipResponse & { rendered?: string }>;
update(params: any): Promise<ZulipResponse>;
flags: {
add(params: { flag: string; messages: number[] }): Promise<ZulipResponse>;
remove(params: { flag: string; messages: number[] }): Promise<ZulipResponse>;
};
getById(params: { message_id: number }): Promise<ZulipResponse & { message?: any }>;
getHistoryById(params: { message_id: number }): Promise<ZulipResponse>;
deleteReactionById(params: { message_id: number; emoji_name?: string }): Promise<ZulipResponse>;
deleteById(params: { message_id: number }): Promise<ZulipResponse>;
};
queues: {
register(params?: RegisterQueueParams): Promise<RegisterQueueResponse>;
deregister(params: DeregisterQueueParams): Promise<ZulipResponse>;
};
events: {
retrieve(params: GetEventsParams): Promise<GetEventsResponse>;
};
streams: {
retrieve(): Promise<ZulipResponse & { streams?: any[] }>;
getStreamId(params: { stream: string }): Promise<ZulipResponse & { stream_id?: number }>;
subscriptions: {
retrieve(): Promise<ZulipResponse & { subscriptions?: any[] }>;
};
deleteById(params: { stream_id: number }): Promise<ZulipResponse>;
topics: {
retrieve(params: { stream_id: number }): Promise<ZulipResponse & { topics?: any[] }>;
};
};
typing: {
send(params: { to: string | string[]; op: 'start' | 'stop' }): Promise<ZulipResponse>;
};
reactions: {
add(params: { message_id: number; emoji_name: string; emoji_code?: string; reaction_type?: string }): Promise<ZulipResponse>;
remove(params: { message_id: number; emoji_code: string; reaction_type?: string }): Promise<ZulipResponse>;
};
emojis: {
retrieve(): Promise<ZulipResponse & { emoji?: any }>;
};
filters: {
retrieve(): Promise<ZulipResponse & { filters?: any[] }>;
};
server: {
settings(): Promise<ZulipResponse & { [key: string]: any }>;
};
accounts: {
retrieve(): Promise<ZulipResponse & { api_key?: string }>;
};
callEndpoint(endpoint: string, method: string, params?: any): Promise<ZulipResponse>;
}
/**
* Zulip初始化函数
*/
function zulipInit(config: ZulipConfig): Promise<ZulipClient>;
export = zulipInit;
}

View File

@@ -2,26 +2,32 @@
* Zulip集成业务模块
*
* 功能描述:
* - 整合Zulip集成相关的控制器、服务和依赖
* - 提供完整的Zulip集成功能模块
* - 实现游戏与Zulip的无缝通信桥梁
* - 支持WebSocket网关、会话管理、消息过滤等核心功能
* - 启动时自动检查并创建所有地图对应的Zulip Streams
* - 整合Zulip集成相关的业务逻辑和控制器
* - 提供完整的Zulip集成业务功能模块
* - 实现游戏与Zulip的业务逻辑协调
* - 支持WebSocket网关、会话管理、消息过滤等业务功能
*
* 核心服务
* - ZulipService: 主协调服务,处理登录、消息发送等核心业务
* 架构设计
* - 业务逻辑层:处理游戏相关的业务规则和流程
* - 核心服务层封装技术实现细节和第三方API调用
* - 通过依赖注入实现业务层与技术层的解耦
*
* 业务服务:
* - ZulipService: 主协调服务,处理登录、消息发送等核心业务流程
* - ZulipWebSocketGateway: WebSocket统一网关处理客户端连接
* - ZulipClientPoolService: Zulip客户端池管理
* - SessionManagerService: 会话状态管理
* - MessageFilterService: 消息过滤和安全控制
* - SessionManagerService: 会话状态管理和业务逻辑
* - MessageFilterService: 消息过滤和业务规则控制
*
* 核心服务通过ZulipCoreModule提供
* - ZulipClientService: Zulip REST API封装
* - ZulipClientPoolService: 客户端池管理
* - ConfigManagerService: 配置管理和热重载
* - StreamInitializerService: Stream初始化和自动创建
* - ErrorHandlerService: 错误处理和服务降级
* - MonitoringService: 系统监控和告警
* - ApiKeySecurityService: API Key安全存储
* - ZulipEventProcessorService: 事件处理和消息转换
* - 其他技术支持服务
*
* 依赖模块:
* - LoginModule: 用户认证和会话管理
* - ZulipCoreModule: Zulip核心技术服务
* - LoginCoreModule: 用户认证和会话管理
* - RedisModule: 会话状态缓存
* - LoggerModule: 日志记录服务
*
@@ -29,65 +35,47 @@
* - 游戏客户端通过WebSocket连接进行实时聊天
* - 游戏内消息与Zulip社群的双向同步
* - 基于位置的聊天上下文管理
* - 系统启动时自动初始化所有地图对应的Streams
* - 业务规则驱动的消息过滤和权限控制
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
* @version 2.0.0
* @since 2025-12-31
*/
import { Module } from '@nestjs/common';
import { ZulipWebSocketGateway } from './zulip-websocket.gateway';
import { ZulipWebSocketGateway } from './zulip_websocket.gateway';
import { ZulipService } from './zulip.service';
import { ZulipClientService } from './services/zulip-client.service';
import { ZulipClientPoolService } from './services/zulip-client-pool.service';
import { SessionManagerService } from './services/session-manager.service';
import { SessionCleanupService } from './services/session-cleanup.service';
import { MessageFilterService } from './services/message-filter.service';
import { ZulipEventProcessorService } from './services/zulip-event-processor.service';
import { ConfigManagerService } from './services/config-manager.service';
import { ErrorHandlerService } from './services/error-handler.service';
import { MonitoringService } from './services/monitoring.service';
import { ApiKeySecurityService } from './services/api-key-security.service';
import { StreamInitializerService } from './services/stream-initializer.service';
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 { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
import { RedisModule } from '../../core/redis/redis.module';
import { LoggerModule } from '../../core/utils/logger/logger.module';
import { LoginModule } from '../login/login.module';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
@Module({
imports: [
// Zulip核心服务模块 - 提供技术实现相关的核心服务
ZulipCoreModule,
// Redis模块 - 提供会话状态缓存和数据存储
RedisModule,
// 日志模块 - 提供统一的日志记录服务
LoggerModule,
// 登录模块 - 提供用户认证和Token验证
LoginModule,
LoginCoreModule,
],
providers: [
// 主协调服务 - 整合各子服务,提供统一业务接口
ZulipService,
// Zulip客户端服务 - 封装Zulip REST API调用
ZulipClientService,
// Zulip客户端池服务 - 管理用户专用Zulip客户端实例
ZulipClientPoolService,
// 会话管理服务 - 维护Socket_ID与Zulip_Queue_ID的映射关系
SessionManagerService,
// 会话清理服务 - 定时清理过期会话
SessionCleanupService,
// 消息过滤服务 - 敏感词过滤、频率限制、权限验证
MessageFilterService,
// Zulip事件处理服务 - 处理Zulip事件队列消息
ZulipEventProcessorService,
// 配置管理服务 - 地图映射配置和系统配置管理
ConfigManagerService,
// Stream初始化服务 - 启动时检查并创建所有地图对应的Streams
StreamInitializerService,
// 错误处理服务 - 错误处理、重试机制、服务降级
ErrorHandlerService,
// 监控服务 - 系统监控、健康检查、告警
MonitoringService,
// API Key安全服务 - API Key加密存储和安全日志
ApiKeySecurityService,
// 会话清理服务 - 定时清理过期会话
SessionCleanupService,
// WebSocket网关 - 处理游戏客户端WebSocket连接
ZulipWebSocketGateway,
],
@@ -95,26 +83,14 @@ import { LoginModule } from '../login/login.module';
exports: [
// 导出主服务供其他模块使用
ZulipService,
// 导出Zulip客户端服务
ZulipClientService,
// 导出客户端池服务
ZulipClientPoolService,
// 导出会话管理服务
SessionManagerService,
// 导出会话清理服务
SessionCleanupService,
// 导出消息过滤服务
MessageFilterService,
// 导出配置管理服务
ConfigManagerService,
// 导出Stream初始化服务
StreamInitializerService,
// 导出错误处理服务
ErrorHandlerService,
// 导出监控服务
MonitoringService,
// 导出API Key安全服务
ApiKeySecurityService,
// 导出事件处理服务
ZulipEventProcessorService,
// 导出会话清理服务
SessionCleanupService,
// 导出WebSocket网关
ZulipWebSocketGateway,
],

File diff suppressed because it is too large Load Diff

View File

@@ -22,14 +22,15 @@
* @since 2025-12-25
*/
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Inject } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ZulipClientPoolService } from './services/zulip-client-pool.service';
import { SessionManagerService } from './services/session-manager.service';
import { MessageFilterService } from './services/message-filter.service';
import { ZulipEventProcessorService } from './services/zulip-event-processor.service';
import { ConfigManagerService } from './services/config-manager.service';
import { ErrorHandlerService } from './services/error-handler.service';
import { SessionManagerService } from './services/session_manager.service';
import { MessageFilterService } from './services/message_filter.service';
import { ZulipEventProcessorService } from './services/zulip_event_processor.service';
import {
IZulipClientPoolService,
IZulipConfigService,
} from '../../core/zulip/interfaces/zulip-core.interfaces';
/**
* 玩家登录请求接口
@@ -79,18 +80,40 @@ export interface ChatMessageResponse {
error?: string;
}
/**
* Zulip集成主服务类
*
* 职责:
* - 作为Zulip集成系统的主要协调服务
* - 整合各个子服务,提供统一的业务接口
* - 处理游戏客户端与Zulip之间的核心业务逻辑
* - 管理玩家会话和消息路由
*
* 主要方法:
* - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化
* - handlePlayerLogout(): 处理玩家登出和资源清理
* - sendChatMessage(): 处理游戏聊天消息发送到Zulip
* - updatePlayerPosition(): 更新玩家位置信息
*
* 使用场景:
* - WebSocket网关调用处理消息路由
* - 会话管理和状态维护
* - 消息格式转换和过滤
* - 游戏与Zulip的双向通信桥梁
*/
@Injectable()
export class ZulipService {
private readonly logger = new Logger(ZulipService.name);
private readonly DEFAULT_MAP = 'whale_port';
constructor(
private readonly zulipClientPool: ZulipClientPoolService,
@Inject('ZULIP_CLIENT_POOL_SERVICE')
private readonly zulipClientPool: IZulipClientPoolService,
private readonly sessionManager: SessionManagerService,
private readonly messageFilter: MessageFilterService,
private readonly eventProcessor: ZulipEventProcessorService,
private readonly configManager: ConfigManagerService,
private readonly errorHandler: ErrorHandlerService,
@Inject('ZULIP_CONFIG_SERVICE')
private readonly configManager: IZulipConfigService,
) {
this.logger.log('ZulipService初始化完成');
}

View File

@@ -56,7 +56,7 @@ describeE2E('Zulip Integration E2E Tests', () => {
});
client.on('connect', () => resolve(client));
client.on('connect_error', (err) => reject(err));
client.on('connect_error', (err: any) => reject(err));
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});

View File

@@ -16,9 +16,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import * as fc from 'fast-check';
import { ZulipWebSocketGateway } from './zulip-websocket.gateway';
import { ZulipWebSocketGateway } from './zulip_websocket.gateway';
import { ZulipService, LoginResponse, ChatMessageResponse } from './zulip.service';
import { SessionManagerService, GameSession } from './services/session-manager.service';
import { SessionManagerService, GameSession } from './services/session_manager.service';
import { Server, Socket } from 'socket.io';
describe('ZulipWebSocketGateway', () => {

View File

@@ -35,7 +35,7 @@ import {
import { Server, Socket } from 'socket.io';
import { Injectable, Logger } from '@nestjs/common';
import { ZulipService } from './zulip.service';
import { SessionManagerService } from './services/session-manager.service';
import { SessionManagerService } from './services/session_manager.service';
/**
* - guide.md格式
@@ -96,6 +96,29 @@ interface ClientData {
connectedAt: Date;
}
/**
* Zulip WebSocket网关类
*
*
* - Godot游戏客户端的WebSocket连接
* - Zulip协议的转换
* -
* -
*
*
* - handleConnection():
* - handleDisconnect():
* - handleLogin():
* - handleChat():
* - handlePositionUpdate():
* - sendChatRender():
*
* 使
* - WebSocket通信的统一入口
* -
* -
* - 广
*/
@Injectable()
@WebSocketGateway({
cors: { origin: '*' },