refactor:重构Zulip模块按业务功能模块化架构
- 将技术实现服务从business层迁移到core层 - 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务 - 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则 - 通过依赖注入实现业务层与核心层的解耦 - 更新模块导入关系,确保架构分层清晰 重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
This commit is contained in:
172
src/business/zulip/README.md
Normal file
172
src/business/zulip/README.md
Normal 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调用逻辑
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
这种架构设计确保了业务逻辑与技术实现的清晰分离,提高了代码的可维护性和可测试性。
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Zulip配置模块导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 统一导出所有Zulip配置相关的接口和函数
|
||||
* - 提供配置加载和验证功能
|
||||
*
|
||||
* @author angjustinl
|
||||
* @version 1.0.0
|
||||
* @since 2025-12-25
|
||||
*/
|
||||
|
||||
export * from './zulip.config';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 Key(16-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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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); // 总共700,70%
|
||||
expect(service.getLoadStatus()).toBe(LoadStatus.HIGH);
|
||||
|
||||
service.updateActiveConnections(200); // 总共900,90%
|
||||
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
@@ -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,
|
||||
},
|
||||
],
|
||||
@@ -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初始化完成');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
650
src/business/zulip/services/session_cleanup.service.spec.ts
Normal file
650
src/business/zulip/services/session_cleanup.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
@@ -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('开始上下文注入', {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
@@ -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初始化完成');
|
||||
}
|
||||
194
src/business/zulip/types/zulip-js.d.ts
vendored
194
src/business/zulip/types/zulip-js.d.ts
vendored
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
1134
src/business/zulip/zulip.service.spec.ts
Normal file
1134
src/business/zulip/zulip.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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初始化完成');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
@@ -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: '*' },
|
||||
Reference in New Issue
Block a user