chore: sync changes for v0.1.1

This commit is contained in:
万物街
2025-08-29 00:10:44 +08:00
parent 9dded57fb7
commit 4009b88ff0
73 changed files with 3128 additions and 1740 deletions

View File

@@ -1,384 +0,0 @@
# 队列系统设计文档
## 概述
本队列系统实现了事件与任务分离的设计理念:
- **事件Events**: 走 Kafka 或 Outbox→Kafka用于领域事件的发布和订阅
- **任务Tasks**: 走 Redis 或 Outbox→Worker用于异步任务的处理
- **Outbox 模式**: 支持数据库作为 Outbox确保事务一致性
## 架构设计
```
┌─────────────────┐ ┌─────────────────┐
│ 业务服务 │ │ 统一队列服务 │
│ │────│ │
│ - 用户服务 │ │ UnifiedQueue │
│ - 订单服务 │ │ Service │
│ - 支付服务 │ └─────────────────┘
└─────────────────┘ │
┌───────────────────────┼───────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ 任务队列提供者 │ │ 事件总线提供者 │ │ 队列工厂服务 │
│ │ │ │ │ │
│ ITaskQueue │ │ IEventBus │ │ QueueFactory │
│ Provider │ │ Provider │ │ Service │
└────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
┌───────┼───────┐ ┌────────┼────────┐ │
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
Redis Database Memory Kafka Database Memory 配置管理
Queue Outbox Queue Events Outbox Events
```
## 核心组件
### 1. 接口定义 (`queue.interface.ts`)
```typescript
// 任务队列接口
export abstract class ITaskQueueProvider {
abstract addTask<T>(queueName: string, taskName: string, data: T, options?: TaskJobOptions): Promise<TaskJob<T>>;
abstract processTask<T>(queueName: string, taskName: string, processor: TaskProcessor<T>): Promise<void>;
abstract getStats(queueName: string): Promise<any>;
abstract clean(queueName: string, grace: number): Promise<void>;
abstract pause(queueName: string): Promise<void>;
abstract resume(queueName: string): Promise<void>;
abstract close(): Promise<void>;
}
// 事件总线接口
export abstract class IEventBusProvider {
abstract publish<T>(event: DomainEvent<T>, options?: EventPublishOptions): Promise<void>;
abstract subscribe<T>(eventType: string, handler: EventHandler<T>, options?: any): Promise<void>;
abstract close(): Promise<void>;
}
```
### 2. 实现提供者
#### Redis 任务队列 (`redis-task-queue.provider.ts`)
- 基于 BullMQ 实现
- 支持任务重试、延迟执行、优先级
- 提供任务统计和管理功能
#### Kafka 事件总线 (`kafka-event-bus.provider.ts`)
- 基于 KafkaJS 实现
- 支持事件发布和订阅
- 提供消费者组管理
#### 数据库 Outbox (`database-queue.provider.ts`)
- 同时实现任务队列和事件总线接口
- 基于数据库事务确保一致性
- 支持定时轮询处理
### 3. 队列工厂 (`queue-factory.service.ts`)
- 根据配置动态创建提供者实例
- 支持运行时切换适配器
- 提供健康检查功能
### 4. 统一服务 (`unified-queue.service.ts`)
- 提供统一的任务和事件操作接口
- 封装常用业务场景的便捷方法
- 支持批量操作
## 配置说明
### 环境变量配置
```bash
# 适配器选择
TASK_QUEUE_ADAPTER=redis # redis, database-outbox, memory
EVENT_BUS_ADAPTER=kafka # kafka, database-outbox, memory
# Redis 配置(任务队列)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Kafka 配置(事件总线)
KAFKA_CLIENT_ID=wwjcloud
KAFKA_BROKERS=localhost:9092
KAFKA_GROUP_ID=wwjcloud-group
KAFKA_TOPIC_PREFIX=domain-events
# 队列配置
QUEUE_REMOVE_ON_COMPLETE=100
QUEUE_REMOVE_ON_FAIL=50
QUEUE_DEFAULT_ATTEMPTS=3
QUEUE_BACKOFF_DELAY=2000
# Outbox 配置
OUTBOX_PROCESS_INTERVAL=5000 # 处理间隔(毫秒)
OUTBOX_BATCH_SIZE=100 # 批处理大小
OUTBOX_MAX_RETRIES=5 # 最大重试次数
OUTBOX_RETRY_DELAY=60000 # 重试延迟(毫秒)
```
### 配置文件 (`src/config/queue/index.ts`)
```typescript
export const queueConfig = () => ({
// 适配器配置
taskAdapter: process.env.TASK_QUEUE_ADAPTER || 'database-outbox',
eventAdapter: process.env.EVENT_BUS_ADAPTER || 'database-outbox',
// Redis 任务队列配置
removeOnComplete: parseInt(process.env.QUEUE_REMOVE_ON_COMPLETE || '100'),
removeOnFail: parseInt(process.env.QUEUE_REMOVE_ON_FAIL || '50'),
defaultAttempts: parseInt(process.env.QUEUE_DEFAULT_ATTEMPTS || '3'),
backoffDelay: parseInt(process.env.QUEUE_BACKOFF_DELAY || '2000'),
// Outbox 模式配置
outboxProcessInterval: parseInt(process.env.OUTBOX_PROCESS_INTERVAL || '5000'),
outboxBatchSize: parseInt(process.env.OUTBOX_BATCH_SIZE || '100'),
outboxMaxRetries: parseInt(process.env.OUTBOX_MAX_RETRIES || '5'),
outboxRetryDelay: parseInt(process.env.OUTBOX_RETRY_DELAY || '60000'),
});
```
## 使用示例
### 1. 基本使用
```typescript
import { Injectable } from '@nestjs/common';
import { UnifiedQueueService } from '@/core/queue/unified-queue.service';
@Injectable()
export class UserService {
constructor(
private readonly queueService: UnifiedQueueService,
) {}
async registerUser(userData: any) {
// 1. 发布用户注册事件
await this.queueService.publishUserEvent('registered', userData.id, {
email: userData.email,
name: userData.name,
});
// 2. 添加发送欢迎邮件任务
await this.queueService.sendEmail(
userData.email,
'欢迎注册',
`欢迎 ${userData.name}`,
{ delay: 5000 }
);
}
}
```
### 2. 注册处理器
```typescript
@Injectable()
export class QueueProcessorService implements OnModuleInit {
constructor(
private readonly queueService: UnifiedQueueService,
) {}
async onModuleInit() {
// 注册任务处理器
await this.queueService.processTask('email', 'send', async (job) => {
console.log('发送邮件:', job.data);
// 实际邮件发送逻辑
});
// 注册事件处理器
await this.queueService.subscribeEvent('user.registered', async (event) => {
console.log('用户注册事件:', event);
// 处理用户注册后的业务逻辑
});
}
}
```
### 3. 批量操作
```typescript
// 批量发布事件
const events = users.map(user => ({
eventId: `user-update-${user.id}-${Date.now()}`,
eventType: 'user.updated',
aggregateId: user.id.toString(),
aggregateType: 'User',
data: user.changes,
version: 1,
occurredAt: Date.now(),
}));
await this.queueService.publishEvents(events);
```
## 数据库表结构
### jobs 表(任务)
```sql
CREATE TABLE `jobs` (
`id` int NOT NULL AUTO_INCREMENT,
`queue_name` varchar(255) NOT NULL COMMENT '队列名称',
`job_name` varchar(255) NOT NULL COMMENT '任务名称',
`payload` text NOT NULL COMMENT '任务数据',
`attempts` int DEFAULT '0' COMMENT '尝试次数',
`max_attempts` int DEFAULT '3' COMMENT '最大尝试次数',
`available_at` int NOT NULL COMMENT '可执行时间',
`created_at` int NOT NULL COMMENT '创建时间',
`status` enum('pending','processing','completed','failed') DEFAULT 'pending',
PRIMARY KEY (`id`),
KEY `idx_jobs_queue_status_available_at` (`queue_name`,`status`,`available_at`)
);
```
### events 表(事件)
```sql
CREATE TABLE `events` (
`id` int NOT NULL AUTO_INCREMENT,
`event_id` varchar(36) NOT NULL COMMENT '事件唯一标识',
`event_type` varchar(255) NOT NULL COMMENT '事件类型',
`aggregate_id` varchar(255) NOT NULL COMMENT '聚合根ID',
`aggregate_type` varchar(255) NOT NULL COMMENT '聚合根类型',
`event_data` text NOT NULL COMMENT '事件数据',
`occurred_at` int NOT NULL COMMENT '发生时间',
`processed_at` int DEFAULT '0' COMMENT '处理时间',
`status` enum('pending','processing','processed','failed') DEFAULT 'pending',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_events_event_id` (`event_id`),
KEY `idx_events_type_processed` (`event_type`,`processed_at`)
);
```
## 部署配置
### 1. 开发环境(使用 Database Outbox
```bash
TASK_QUEUE_ADAPTER=database-outbox
EVENT_BUS_ADAPTER=database-outbox
```
### 2. 生产环境(使用 Redis + Kafka
```bash
TASK_QUEUE_ADAPTER=redis
EVENT_BUS_ADAPTER=kafka
REDIS_HOST=redis.example.com
REDIS_PORT=6379
REDIS_PASSWORD=your-password
KAFKA_BROKERS=kafka1.example.com:9092,kafka2.example.com:9092
KAFKA_GROUP_ID=wwjcloud-prod
```
### 3. 混合环境(任务用 Redis事件用 Database
```bash
TASK_QUEUE_ADAPTER=redis
EVENT_BUS_ADAPTER=database-outbox
```
## 监控和运维
### 1. 健康检查
```typescript
const health = await queueService.healthCheck();
console.log(health);
// {
// taskQueue: { status: 'healthy', details: {...} },
// eventBus: { status: 'healthy', details: {...} }
// }
```
### 2. 队列统计
```typescript
const stats = await queueService.getTaskQueueStats('email');
console.log(stats);
// {
// waiting: 10,
// active: 2,
// completed: 100,
// failed: 5
// }
```
### 3. 队列管理
```typescript
// 暂停队列
await queueService.pauseTaskQueue('email');
// 恢复队列
await queueService.resumeTaskQueue('email');
// 清理已完成任务
await queueService.cleanTaskQueue('email', 3600000); // 保留1小时
```
## 最佳实践
### 1. 事件设计
- 事件应该是过去时态,描述已经发生的事情
- 事件数据应该包含足够的上下文信息
- 使用版本控制来处理事件结构变化
### 2. 任务设计
- 任务应该是幂等的,可以安全重试
- 任务数据应该包含所有必要的信息
- 合理设置重试次数和延迟时间
### 3. 错误处理
- 实现适当的错误处理和重试机制
- 记录详细的错误日志
- 设置死信队列处理失败任务
### 4. 性能优化
- 合理设置批处理大小
- 使用适当的并发数
- 定期清理已完成的任务和事件
## 故障排查
### 1. 任务不执行
- 检查队列配置是否正确
- 确认任务处理器已注册
- 查看任务状态和错误日志
### 2. 事件丢失
- 检查事件总线连接状态
- 确认事件处理器已注册
- 查看事件表中的处理状态
### 3. 性能问题
- 监控队列长度和处理速度
- 检查数据库连接池配置
- 优化任务和事件处理逻辑
## 扩展开发
### 1. 添加新的适配器
1. 实现 `ITaskQueueProvider``IEventBusProvider` 接口
2.`QueueFactoryService` 中添加创建逻辑
3. 更新配置和文档
### 2. 自定义任务类型
1. 定义任务数据结构
2. 实现任务处理器
3. 在统一服务中添加便捷方法
### 3. 监控集成
1. 添加指标收集
2. 集成监控系统
3. 设置告警规则
## 源码仓库
项目托管在 Gitee 上https://gitee.com/your-org/wwjcloud-nestjs
## 贡献指南
1. Fork 项目到你的 Gitee 账户
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交你的修改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request

View File

@@ -1,245 +0,0 @@
import { Injectable } from '@nestjs/common';
import { UnifiedQueueService } from '../src/core/queue/unifiedQueueService';
import { TaskJobOptions, EventPublishOptions } from '../src/core/interfaces/queue.interface';
/**
* 队列使用示例
* 演示如何使用新的队列系统
*/
@Injectable()
export class QueueUsageExample {
constructor(
private readonly queueService: UnifiedQueueService,
) {}
/**
* 示例:用户注册流程
*/
async handleUserRegistration(userData: {
id: number;
email: string;
phone: string;
name: string;
}) {
// 1. 发布用户注册事件(事件总线)
await this.queueService.publishUserEvent('registered', userData.id, {
email: userData.email,
phone: userData.phone,
name: userData.name,
registeredAt: new Date().toISOString(),
});
// 2. 添加发送欢迎邮件任务(任务队列)
await this.queueService.sendEmail(
userData.email,
'欢迎注册',
`欢迎 ${userData.name} 注册我们的平台!`,
{
delay: 5000, // 5秒后发送
attempts: 3,
priority: 1,
}
);
// 3. 添加发送短信验证码任务(任务队列)
await this.queueService.sendSms(
userData.phone,
'您的验证码是123456',
{
attempts: 2,
priority: 1,
}
);
// 4. 添加数据同步任务(任务队列)
await this.queueService.syncData('user-profile', {
userId: userData.id,
action: 'create',
data: userData,
}, {
delay: 10000, // 10秒后同步
priority: 3,
});
}
/**
* 示例:订单处理流程
*/
async handleOrderCreated(orderData: {
id: number;
userId: number;
amount: number;
items: any[];
}) {
// 1. 发布订单创建事件(事件总线)
await this.queueService.publishOrderEvent('created', orderData.id, {
userId: orderData.userId,
amount: orderData.amount,
itemCount: orderData.items.length,
createdAt: new Date().toISOString(),
});
// 2. 添加库存扣减任务(任务队列)
await this.queueService.addTask('inventory', 'reduce', {
orderId: orderData.id,
items: orderData.items,
}, {
priority: 1,
attempts: 5,
});
// 3. 添加支付处理任务(任务队列)
await this.queueService.addTask('payment', 'process', {
orderId: orderData.id,
amount: orderData.amount,
userId: orderData.userId,
}, {
delay: 1000, // 1秒后处理
priority: 1,
attempts: 3,
});
}
/**
* 示例:批量事件发布
*/
async handleBatchUserUpdate(users: Array<{ id: number; changes: any }>) {
const events = users.map(user => ({
eventType: 'user.updated',
aggregateId: user.id.toString(),
tenantId: 'default',
idempotencyKey: `user-update-${user.id}-${Date.now()}`,
traceId: `trace-${Date.now()}`,
data: user.changes,
version: '1',
occurredAt: new Date().toISOString(),
}));
// 批量发布事件
await this.queueService.publishEvents(events);
}
/**
* 示例:注册任务处理器
*/
async registerTaskProcessors() {
// 注册邮件发送处理器
await this.queueService.processTask('email', async (job: any) => {
console.log('处理邮件发送任务:', job.data);
// 实际的邮件发送逻辑
await this.sendEmailImplementation(job.data);
});
// 注册短信发送处理器
await this.queueService.processTask('sms', async (job: any) => {
console.log('处理短信发送任务:', job.data);
// 实际的短信发送逻辑
await this.sendSmsImplementation(job.data);
});
// 注册库存扣减处理器
await this.queueService.processTask('inventory', async (job: any) => {
console.log('处理库存扣减任务:', job.data);
// 实际的库存扣减逻辑
await this.reduceInventoryImplementation(job.data);
});
// 注册支付处理器
await this.queueService.processTask('payment', async (job: any) => {
console.log('处理支付任务:', job.data);
// 实际的支付处理逻辑
await this.processPaymentImplementation(job.data);
});
}
/**
* 示例:注册事件处理器
*/
async registerEventHandlers() {
// 注册用户注册事件处理器
await this.queueService.subscribeEvent('user.registered', async (event: any) => {
console.log('处理用户注册事件:', event);
// 可以触发其他业务逻辑,如发送通知、更新统计等
await this.handleUserRegisteredEvent(event);
});
// 注册订单创建事件处理器
await this.queueService.subscribeEvent('order.created', async (event: any) => {
console.log('处理订单创建事件:', event);
// 可以触发其他业务逻辑,如发送通知、更新报表等
await this.handleOrderCreatedEvent(event);
});
// 注册用户更新事件处理器
await this.queueService.subscribeEvent('user.updated', async (event: any) => {
console.log('处理用户更新事件:', event);
// 可以触发缓存更新、搜索索引更新等
await this.handleUserUpdatedEvent(event);
});
}
/**
* 示例:监控和管理
*/
async monitorQueues() {
// 获取任务队列统计
const emailStats = await this.queueService.getTaskQueueStats('email');
const smsStats = await this.queueService.getTaskQueueStats('sms');
console.log('邮件队列统计:', emailStats);
console.log('短信队列统计:', smsStats);
// 健康检查
const health = await this.queueService.healthCheck();
console.log('队列健康状态:', health);
// 清理已完成的任务保留最近1小时的
await this.queueService.cleanTaskQueue('email', 3600000);
await this.queueService.cleanTaskQueue('sms', 3600000);
}
// ==================== 私有实现方法 ====================
private async sendEmailImplementation(data: any) {
// 实际的邮件发送实现
console.log('发送邮件:', data);
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 1000));
}
private async sendSmsImplementation(data: any) {
// 实际的短信发送实现
console.log('发送短信:', data);
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 500));
}
private async reduceInventoryImplementation(data: any) {
// 实际的库存扣减实现
console.log('扣减库存:', data);
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 2000));
}
private async processPaymentImplementation(data: any) {
// 实际的支付处理实现
console.log('处理支付:', data);
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 3000));
}
private async handleUserRegisteredEvent(event: any) {
// 处理用户注册事件的业务逻辑
console.log('用户注册事件处理:', event);
}
private async handleOrderCreatedEvent(event: any) {
// 处理订单创建事件的业务逻辑
console.log('订单创建事件处理:', event);
}
private async handleUserUpdatedEvent(event: any) {
// 处理用户更新事件的业务逻辑
console.log('用户更新事件处理:', event);
}
}

View File

@@ -61,7 +61,7 @@
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.11.0",
"bcrypt": "^6.0.0",
"bull": "^4.16.5",
"bullmq": "^5.7.0",
"cache-manager": "^7.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",

View File

@@ -11,27 +11,4 @@ export class AppController {
getHello(): string {
return this.appService.getHello();
}
@Get('healthz')
@Public()
healthCheck() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
};
}
@Get('readyz')
@Public()
readinessCheck() {
return {
status: 'ready',
timestamp: new Date().toISOString(),
services: {
redis: 'connected',
kafka: 'connected',
},
};
}
}

View File

@@ -41,8 +41,10 @@ import { ConfigModule } from './config';
// 新增:全局异常过滤器、统一响应、健康
import { HttpExceptionFilter } from './core/http/filters/httpExceptionFilter';
import { ResponseInterceptor } from './core/http/interceptors/responseInterceptor';
import { HealthController } from './core/observability/health/health.controller';
import { HealthController as ObHealthController } from './core/observability/health/health.controller';
import { HealthModule as K8sHealthModule } from './core/health/healthModule';
import { HttpMetricsService } from './core/observability/metrics/httpMetricsService';
import { OutboxKafkaForwarderModule } from './core/event/outboxKafkaForwarder.module';
import { HealthAggregator } from './core/observability/health/health-aggregator';
import { DbHealthIndicator } from './core/observability/health/indicators/db.indicator';
import { RedisHealthIndicator } from './core/observability/health/indicators/redis.indicator';
@@ -108,6 +110,8 @@ const dbImports =
LOG_LEVEL: Joi.string(),
THROTTLE_TTL: Joi.number(),
THROTTLE_LIMIT: Joi.number(),
STARTUP_HEALTH_CHECK: Joi.string().valid('true', 'false').optional(),
STARTUP_HEALTH_TIMEOUT_MS: Joi.number().min(100).optional(),
}),
}),
// 缓存(内存实现,后续可替换为 redis-store
@@ -137,45 +141,47 @@ const dbImports =
],
}),
}),
// 健康检查(需要时可增加控制器
// 健康检查(Terminus 聚合
TerminusModule,
// K8s 探针端点
K8sHealthModule,
// 日志
WinstonModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
level: config.get('logLevel'),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, context }) => {
try {
const { ClsServiceManager } = require('nestjs-cls');
const cls = ClsServiceManager.getClsService?.();
const rid = cls?.get?.('requestId');
const tp = cls?.get?.('traceparent');
const ctx = context ? ` [${context}]` : '';
const ids =
rid || tp
? ` rid=${rid ?? ''}${tp ? ` trace=${tp}` : ''}`
: '';
return `${timestamp} [${level}]${ctx} ${message}${ids}`;
} catch {
return `${timestamp} [${level}]${context ? ' [' + context + ']' : ''} ${message}`;
}
}),
),
}),
new (winston.transports as any).DailyRotateFile({
dirname: 'logs',
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: false,
maxFiles: '14d',
level: config.get('logLevel'),
}),
],
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, context }) => {
try {
const { ClsServiceManager } = require('nestjs-cls');
const cls = ClsServiceManager.getClsService?.();
const rid = cls?.get?.('requestId');
const tp = cls?.get?.('traceparent');
const ctx = context ? ` [${context}]` : '';
const ids =
rid || tp
? ` rid=${rid ?? ''}${tp ? ` trace=${tp}` : ''}`
: '';
return `${timestamp} [${level}]${ctx} ${message}${ids}`;
} catch {
return `${timestamp} [${level}]${context ? ' [' + context + ']' : ''} ${message}`;
}
}),
),
}),
new (winston.transports as any).DailyRotateFile({
dirname: 'logs',
filename: 'app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: false,
maxFiles: '14d',
level: config.get('logLevel'),
}),
],
}),
}),
// 请求上下文
@@ -208,12 +214,12 @@ const dbImports =
JobsModule,
// 事件总线模块
EventBusModule,
// 测试模块Redis 和 Kafka 测试)
// TestModule,
// 配置模块(配置中心)
ConfigModule,
// Outbox→Kafka 转发器
OutboxKafkaForwarderModule,
],
controllers: [AppController, MetricsController, HealthController],
controllers: [AppController, MetricsController, ObHealthController],
providers: [
AppService,
// 全局守卫

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Res } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import type { Response } from 'express';
import { SwaggerConfig } from '../integrations/swaggerConfig';
@ApiTags('文档导航')
@Controller()
@@ -10,7 +9,7 @@ export class DocsNavigationController {
@ApiOperation({ summary: 'API文档导航页面' })
@ApiResponse({ status: 200, description: '返回API文档导航HTML页面' })
getApiDocsNavigation(@Res() res: Response) {
const html = SwaggerConfig.getNavigationHtml();
const html = this.getNavigationHtml();
res.setHeader('Content-Type', 'text/html');
res.send(html);
}
@@ -134,4 +133,67 @@ export class DocsNavigationController {
},
};
}
private getNavigationHtml(): string {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WWJCloud API 文档导航</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; margin: 0; background: #f6f8fa; color: #111827; }
.container { max-width: 960px; margin: 40px auto; padding: 0 16px; }
.header { margin-bottom: 20px; }
.title { font-size: 28px; color: #111827; margin: 0; }
.desc { color: #6b7280; margin-top: 8px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; margin-top: 24px; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; text-decoration: none; color: inherit; transition: box-shadow .2s, transform .2s; }
.card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.08); transform: translateY(-2px); }
.card .icon { font-size: 22px; }
.card .title { font-size: 18px; margin: 8px 0; color: #111827; }
.card .text { color: #6b7280; font-size: 14px; }
.footer { margin-top: 28px; color: #6b7280; font-size: 14px; }
.links a { color: #2563eb; text-decoration: none; margin-right: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">WWJCloud API 文档导航</h1>
<p class="desc">企业级后端 API 文档导航中心</p>
</div>
<div class="grid">
<a class="card" href="/docs">
<div class="icon">📖</div>
<div class="title">完整API文档</div>
<div class="text">包含所有接口的完整API文档</div>
</a>
<a class="card" href="/docs/admin">
<div class="icon">🔐</div>
<div class="title">管理端API</div>
<div class="text">管理后台专用接口文档</div>
</a>
<a class="card" href="/docs/frontend">
<div class="icon">🌐</div>
<div class="title">前端API</div>
<div class="text">前端应用接口文档</div>
</a>
<a class="card" href="/docs/settings">
<div class="icon">⚙️</div>
<div class="title">系统设置API</div>
<div class="text">系统配置和设置相关接口</div>
</a>
</div>
<div class="footer">
<div>提示点击上方卡片访问对应的API文档</div>
<div class="links" style="margin-top:8px;">
<a href="/docs-json">JSON格式文档</a>
<a href="/health">系统健康检查</a>
</div>
</div>
</div>
</body>
</html>`;
}
}

View File

@@ -75,6 +75,12 @@ export interface AppConfig {
limit: number;
};
// 启动健康检查配置
health: {
startupCheckEnabled: boolean;
startupTimeoutMs: number;
};
// 第三方服务配置
thirdParty: {
storage: {
@@ -106,8 +112,8 @@ const defaultConfig: AppConfig = {
database: {
host: 'localhost',
port: 3306,
username: 'root',
password: '123456',
username: 'wwjcloud',
password: 'wwjcloud',
database: 'wwjcloud',
synchronize: false,
logging: true,
@@ -149,6 +155,10 @@ const defaultConfig: AppConfig = {
ttl: 60,
limit: 100,
},
health: {
startupCheckEnabled: true,
startupTimeoutMs: 5000,
},
thirdParty: {
storage: {
provider: 'local',
@@ -223,6 +233,13 @@ function loadFromEnv(): Partial<AppConfig> {
ttl: parseInt(process.env.THROTTLE_TTL || String(defaultConfig.throttle.ttl), 10),
limit: parseInt(process.env.THROTTLE_LIMIT || String(defaultConfig.throttle.limit), 10),
},
health: {
startupCheckEnabled: (process.env.STARTUP_HEALTH_CHECK || 'true').toLowerCase() !== 'false',
startupTimeoutMs: parseInt(
process.env.STARTUP_HEALTH_TIMEOUT_MS || String(defaultConfig.health.startupTimeoutMs),
10,
),
},
thirdParty: {
storage: {
provider: process.env.STORAGE_PROVIDER || defaultConfig.thirdParty.storage.provider,
@@ -256,6 +273,7 @@ function mergeConfig(defaultConfig: AppConfig, envConfig: Partial<AppConfig>): A
logging: { ...defaultConfig.logging, ...envConfig.logging },
upload: { ...defaultConfig.upload, ...envConfig.upload },
throttle: { ...defaultConfig.throttle, ...envConfig.throttle },
health: { ...defaultConfig.health, ...envConfig.health },
thirdParty: {
storage: { ...defaultConfig.thirdParty.storage, ...envConfig.thirdParty?.storage },
payment: { ...defaultConfig.thirdParty.payment, ...envConfig.thirdParty?.payment },
@@ -323,6 +341,11 @@ export const config = {
return appConfig.throttle;
},
// 获取健康检查配置
getHealth() {
return appConfig.health;
},
// 获取第三方服务配置
getThirdParty() {
return appConfig.thirdParty;

View File

@@ -6,6 +6,8 @@ import { ConfigValidationService } from '../services/configValidationService';
import { DynamicConfigService } from '../services/dynamicConfigService';
import { DocsNavigationController } from '../controllers/docsNavigationController';
import { appConfig } from './appConfig';
import { SwaggerController } from '../modules/swagger/swaggerController';
import { SwaggerService } from '../modules/swagger/swaggerService';
@Module({
imports: [
@@ -15,11 +17,12 @@ import { appConfig } from './appConfig';
envFilePath: ['.env.local', '.env.development', '.env.production', '.env'],
}),
],
controllers: [ConfigController, DocsNavigationController],
controllers: [ConfigController, DocsNavigationController, SwaggerController],
providers: [
ConfigCenterService,
ConfigValidationService,
DynamicConfigService,
SwaggerService,
{
provide: 'APP_CONFIG',
useValue: appConfig,

View File

@@ -11,9 +11,6 @@ export * from './services';
// 配置控制器
export * from './controllers';
// 集成配置
export * from './integrations';
// 模块配置
export * from './modules';

View File

@@ -1,2 +0,0 @@
// 集成配置导出
export { SwaggerConfig } from './swaggerConfig';

View File

@@ -1,212 +0,0 @@
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { INestApplication } from '@nestjs/common';
/**
* Swagger API文档配置
* 支持按前缀分组展示不同类型的API
*/
export class SwaggerConfig {
/**
* 设置API文档
* @param app NestJS应用实例
*/
static setup(app: INestApplication) {
// 统一API文档包含所有接口
const config = new DocumentBuilder()
.setTitle('WWJCloud API 文档')
.setDescription('WWJCloud 基于 NestJS 的企业级后端 API 文档')
.setVersion('1.0.0')
.addBearerAuth({
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'Authorization',
description: 'JWT Token',
in: 'header',
})
.addTag('健康检查', '系统健康状态检查接口')
.addTag('认证授权', '用户认证和授权相关接口')
.addTag('管理端API', '管理后台专用接口')
.addTag('前端API', '前端应用接口')
.addTag('系统设置', '系统配置和设置接口')
.addTag('文件上传', '文件上传和管理接口')
.addTag('数据库管理', '数据库管理和监控接口')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
docExpansion: 'none',
filter: true,
showRequestDuration: true,
},
customSiteTitle: 'WWJCloud API 文档',
customfavIcon: '/favicon.ico',
customCss: `
.swagger-ui .topbar { display: none; }
.swagger-ui .info .title { color: #3b82f6; }
.swagger-ui .scheme-container { background: #f8fafc; padding: 10px; border-radius: 4px; }
`,
});
console.log('📚 API文档已启动:');
console.log(' - 完整文档: http://localhost:3001/docs');
}
/**
* 获取API文档导航页面HTML
* @returns HTML字符串
*/
static getNavigationHtml(): string {
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WWJCloud API 文档导航</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
padding: 40px;
max-width: 800px;
width: 100%;
}
.header {
text-align: center;
margin-bottom: 40px;
}
.header h1 {
color: #2d3748;
font-size: 2.5rem;
margin-bottom: 10px;
}
.header p {
color: #718096;
font-size: 1.1rem;
}
.docs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.doc-card {
background: #f7fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
text-decoration: none;
transition: all 0.3s ease;
display: block;
}
.doc-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
border-color: #4299e1;
}
.doc-card.complete { border-color: #3b82f6; background: #eff6ff; }
.doc-card.admin { border-color: #dc2626; background: #fef2f2; }
.doc-card.frontend { border-color: #059669; background: #f0fdf4; }
.doc-card.settings { border-color: #7c3aed; background: #faf5ff; }
.doc-card h3 {
font-size: 1.3rem;
margin-bottom: 8px;
color: #2d3748;
}
.doc-card.complete h3 { color: #3b82f6; }
.doc-card.admin h3 { color: #dc2626; }
.doc-card.frontend h3 { color: #059669; }
.doc-card.settings h3 { color: #7c3aed; }
.doc-card p {
color: #4a5568;
line-height: 1.5;
margin-bottom: 12px;
}
.doc-card .badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.doc-card.complete .badge { background: #dbeafe; color: #1e40af; }
.doc-card.admin .badge { background: #fee2e2; color: #991b1b; }
.doc-card.frontend .badge { background: #dcfce7; color: #166534; }
.doc-card.settings .badge { background: #f3e8ff; color: #6b21a8; }
.footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
color: #718096;
}
.footer a {
color: #4299e1;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 WWJCloud API</h1>
<p>企业级后端API文档导航中心</p>
</div>
<div class="docs-grid">
<a href="/docs" class="doc-card complete">
<h3>📖 完整API文档</h3>
<p>包含所有接口的完整API文档适合开发者全面了解系统功能</p>
<span class="badge">Complete</span>
</a>
<a href="/docs/admin" class="doc-card admin">
<h3>🔐 管理端API</h3>
<p>管理后台专用接口文档,包含管理员、权限、系统配置等功能</p>
<span class="badge">Admin</span>
</a>
<a href="/docs/frontend" class="doc-card frontend">
<h3>🌐 前端API</h3>
<p>前端应用接口文档,包含用户认证、会员功能等前端专用接口</p>
<span class="badge">Frontend</span>
</a>
<a href="/docs/settings" class="doc-card settings">
<h3>⚙️ 系统设置API</h3>
<p>系统配置和设置相关接口,包含存储、支付、短信等配置管理</p>
<span class="badge">Settings</span>
</a>
</div>
<div class="footer">
<p>💡 提示点击上方卡片访问对应的API文档 |
<a href="/docs-json" target="_blank">JSON格式文档</a> |
<a href="/health" target="_blank">系统健康检查</a>
</p>
</div>
</div>
</body>
</html>
`;
}
}

View File

@@ -0,0 +1,53 @@
import { Controller, Get, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import type { Request } from 'express';
import { SwaggerService } from './swaggerService';
import { ConfigCenterService } from '../../services/configCenterService';
import { Req } from '@nestjs/common';
@ApiTags('文档')
@Controller()
export class SwaggerController {
constructor(
private readonly docs: SwaggerService,
private readonly configCenter: ConfigCenterService,
) {}
private verifyToken(req: Request) {
const requiredToken = this.configCenter.get<string>('swagger.token', '');
if (!requiredToken) {
throw new UnauthorizedException('Swagger token not configured');
}
const auth = req.headers['authorization'] || '';
const token = typeof auth === 'string' && auth.startsWith('Bearer ')
? auth.slice('Bearer '.length).trim()
: '';
if (token !== requiredToken) {
throw new UnauthorizedException('Invalid token');
}
}
@Get('api-json')
@ApiOperation({ summary: '获取完整 API 文档 JSON' })
@ApiResponse({ status: 200, description: '返回完整 Swagger 文档 JSON' })
getDocsJson(@Req() req: Request) {
this.verifyToken(req);
return this.docs.getDocument();
}
@Get('api/admin-json')
@ApiOperation({ summary: '获取管理端 API 文档 JSON' })
@ApiResponse({ status: 200, description: '返回管理端 Swagger 文档 JSON' })
getAdminDocsJson(@Req() req: Request) {
this.verifyToken(req);
return this.docs.getAdminDocument();
}
@Get('api/frontend-json')
@ApiOperation({ summary: '获取前端 API 文档 JSON' })
@ApiResponse({ status: 200, description: '返回前端 Swagger 文档 JSON' })
getFrontendDocsJson(@Req() req: Request) {
this.verifyToken(req);
return this.docs.getFrontendDocument();
}
}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { OpenAPIObject } from '@nestjs/swagger';
import { ConfigCenterService } from '../../services/configCenterService';
@Injectable()
export class SwaggerService {
private initialized = false;
private document: OpenAPIObject | null = null;
private adminDocument: OpenAPIObject | null = null;
private frontendDocument: OpenAPIObject | null = null;
constructor(private readonly configCenter: ConfigCenterService) {}
getDocument() {
return this.document;
}
getAdminDocument() {
return this.adminDocument;
}
getFrontendDocument() {
return this.frontendDocument;
}
setup(app: INestApplication) {
if (this.initialized) return;
const enabled = this.configCenter.get<boolean>('swagger.enabled', true);
if (!enabled) return;
const title = this.configCenter.get<string>('swagger.title', 'WWJCloud API 文档');
const description = this.configCenter.get<string>('swagger.description', 'WWJCloud 基于 NestJS 的企业级后端 API 文档');
const version = this.configCenter.get<string>('swagger.version', '1.0.0');
const enableAuth = this.configCenter.get<boolean>('swagger.auth.enabled', true);
const builder = new DocumentBuilder().setTitle(title).setDescription(description).setVersion(version);
if (enableAuth) {
builder.addBearerAuth({
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'Authorization',
description: 'JWT Token',
in: 'header',
});
}
const fullDoc = SwaggerModule.createDocument(app, builder.build());
this.document = fullDoc;
const splitByPrefix = (
doc: OpenAPIObject,
prefix: string,
): OpenAPIObject => {
const paths: Record<string, any> = {};
Object.keys(doc.paths || {}).forEach((p) => {
if (p.startsWith(prefix)) paths[p] = (doc.paths as any)[p];
});
return { ...doc, paths } as OpenAPIObject;
};
this.adminDocument = splitByPrefix(fullDoc, '/adminapi');
this.frontendDocument = splitByPrefix(fullDoc, '/api');
this.initialized = true;
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEntity } from '@wwjCore/queue/entities/event.entity';
import { OutboxKafkaForwarderService } from './outboxKafkaForwarder.service';
import { VendorModule } from '@wwjVendor/index';
@Module({
imports: [TypeOrmModule.forFeature([EventEntity]), VendorModule],
providers: [OutboxKafkaForwarderService],
exports: [OutboxKafkaForwarderService],
})
export class OutboxKafkaForwarderModule {}

View File

@@ -0,0 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, Repository } from 'typeorm';
import { EventEntity } from '@wwjCore/queue/entities/event.entity';
import { KafkaProvider } from '@wwjVendor/event/kafka.provider';
@Injectable()
export class OutboxKafkaForwarderService {
private readonly logger = new Logger(OutboxKafkaForwarderService.name);
private readonly batchSize = 50;
constructor(
@InjectRepository(EventEntity)
private readonly eventRepo: Repository<EventEntity>,
private readonly kafka: KafkaProvider,
) {}
@Cron(CronExpression.EVERY_SECOND)
async forward() {
const now = Math.floor(Date.now() / 1000);
const events = await this.eventRepo.find({
where: {
status: 'pending',
next_retry_at: LessThanOrEqual(now),
},
order: { occurred_at: 'ASC' },
take: this.batchSize,
});
for (const e of events) {
try {
const topic = e.event_type; // 建议规范如 iam.user.v1
const key = e.aggregate_id || e.event_id;
const message = {
event_id: e.event_id,
event_type: e.event_type,
aggregate_id: e.aggregate_id,
aggregate_type: e.aggregate_type,
site_id: e.site_id,
trace_id: e.trace_id,
event_version: e.event_version,
occurred_at: e.occurred_at,
data: JSON.parse(e.event_data),
};
await this.kafka.publish(topic, key, message);
await this.eventRepo.update(e.id, {
status: 'processed',
processed_at: Math.floor(Date.now() / 1000),
last_error: null,
});
} catch (err: any) {
const retry = e.retry_count + 1;
const delay = Math.min(60 * 60, Math.pow(2, retry) * 10); // 最大回退1小时
await this.eventRepo.update(e.id, {
status: 'pending',
retry_count: retry,
last_error: String(err?.message ?? err),
next_retry_at: Math.floor(Date.now() / 1000) + delay,
});
this.logger.error(`Forward event failed: id=${e.id} type=${e.event_type} ${err?.message ?? err}`);
}
}
}
}

View File

@@ -1,5 +1,4 @@
import { Module } from '@nestjs/common';
import { HealthController } from './healthController';
import { HealthzController } from './healthzController';
import { HealthService } from './healthService';
import { QueueModule } from '@wwjCore/queue/queueModule';
@@ -15,7 +14,6 @@ import { EventModule } from '@wwjCore/event/eventModule';
EventModule,
],
controllers: [
HealthController,
HealthzController,
],
providers: [HealthService],

View File

@@ -1,13 +1,19 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class DbHealthIndicator {
readonly name = 'db';
constructor(private readonly dataSource: DataSource) {}
async check(): Promise<boolean> {
try {
// TODO: 实现真实的数据库健康检查
// 暂时返回 true表示服务可用
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
await Promise.race<T>([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
await withTimeout(this.dataSource.query('SELECT 1'), 3000);
return true;
} catch {
return false;

View File

@@ -1,13 +1,19 @@
import { Injectable } from '@nestjs/common';
import { KafkaProvider } from '@wwjVendor/event/kafka.provider';
@Injectable()
export class EventBusHealthIndicator {
readonly name = 'eventbus';
constructor(private readonly kafkaProvider: KafkaProvider) {}
async check(): Promise<boolean> {
try {
// TODO: 实现真实的事件总线健康检查
// 暂时返回 true表示服务可用
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
await Promise.race<T>([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
await withTimeout(this.kafkaProvider.ensure(), 3000);
return true;
} catch {
return false;

View File

@@ -1,14 +1,20 @@
import { Injectable } from '@nestjs/common';
import { BullQueueProvider } from '@wwjVendor/queue/bullmq.provider';
@Injectable()
export class QueueHealthIndicator {
readonly name = 'queue';
constructor(private readonly bullQueueProvider: BullQueueProvider) {}
async check(): Promise<boolean> {
try {
// TODO: 实现真实的队列健康检查
// 暂时返回 true表示服务可用
return true;
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
await Promise.race<T>([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
const ok = await withTimeout(this.bullQueueProvider.healthCheck(), 3000);
return !!ok;
} catch {
return false;
}

View File

@@ -1,19 +1,22 @@
import { Injectable } from '@nestjs/common';
// import { RedisProvider } from '../../../vendor/redis/redis.provider';
import { RedisProvider } from '@wwjVendor/redis/redis.provider';
@Injectable()
export class RedisHealthIndicator {
readonly name = 'redis';
constructor() {}
constructor(private readonly redisProvider: RedisProvider) {}
check(): Promise<boolean> {
async check(): Promise<boolean> {
try {
// TODO: 实现 Redis 健康检查
// const pong = await this.redisProvider.ping();
// return pong === 'PONG' || pong === 'pong';
return Promise.resolve(true);
const withTimeout = async <T>(p: Promise<T>, ms: number) =>
await Promise.race<T>([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
const pong = await withTimeout(this.redisProvider.ping(), 2000);
return pong?.toString().toUpperCase() === 'PONG';
} catch {
return Promise.resolve(false);
return false;
}
}
}

View File

@@ -1,16 +1,33 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { promises as fs } from 'fs';
import * as path from 'path';
@Injectable()
export class StorageHealthIndicator {
readonly name = 'storage';
constructor(private readonly configService: ConfigService) {}
check(): Promise<boolean> {
async check(): Promise<boolean> {
try {
// TODO: 实现真实的存储健康检查
// 暂时返回 true表示服务可用
return Promise.resolve(true);
const provider = this.configService.get<string>('thirdParty.storage.provider') || 'local';
if (provider === 'local') {
const uploadPath = this.configService.get<string>('upload.path') || 'uploads/';
const testDir = path.resolve(process.cwd(), uploadPath);
const testFile = path.join(testDir, `.healthcheck_${Date.now()}.tmp`);
try {
await fs.mkdir(testDir, { recursive: true });
await fs.writeFile(testFile, 'ok');
await fs.unlink(testFile);
return true;
} catch {
return false;
}
}
// TODO: 其他存储适配器的轻量健康检查(如 headBucket
return true;
} catch {
return Promise.resolve(false);
return false;
}
}
}

View File

@@ -158,6 +158,8 @@ export class DatabaseQueueProvider implements ITaskQueueProvider, IEventBusProvi
eventRecord.event_type = event.eventType;
eventRecord.aggregate_id = event.aggregateId;
eventRecord.aggregate_type = event.fromDomain || 'default'; // 使用源域或默认值
eventRecord.site_id = (event as any).tenantId ? Number((event as any).tenantId) : 0;
eventRecord.trace_id = (event as any).traceId ?? null;
eventRecord.event_data = JSON.stringify(event.data);
eventRecord.event_version = parseInt(event.version) || 1;
eventRecord.occurred_at = Math.floor(new Date(event.occurredAt).getTime() / 1000); // 转换字符串为时间戳
@@ -230,12 +232,13 @@ export class DatabaseQueueProvider implements ITaskQueueProvider, IEventBusProvi
for (const eventRecord of failedEvents) {
try {
// 重构事件对象
const parsedHeaders = eventRecord.headers ? JSON.parse(eventRecord.headers) : {};
const event: DomainEvent = {
eventType: eventRecord.event_type,
aggregateId: eventRecord.aggregate_id,
tenantId: '', // 从 event_data 中解析
tenantId: (eventRecord as any).site_id ? String((eventRecord as any).site_id) : '',
idempotencyKey: eventRecord.event_id,
traceId: '', // 从 headers 中解析
traceId: (eventRecord as any).trace_id || parsedHeaders.traceId || parsedHeaders['traceparent'] || '',
data: JSON.parse(eventRecord.event_data),
occurredAt: new Date(eventRecord.occurred_at * 1000).toISOString(),
version: eventRecord.event_version.toString(),

View File

@@ -9,6 +9,7 @@ import { BaseEntity } from '@wwjCore/base/BaseEntity';
@Index(['event_type', 'processed_at'])
@Index(['aggregate_id', 'aggregate_type'])
@Index(['occurred_at'])
@Index(['site_id', 'status'])
export class EventEntity extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@@ -37,6 +38,12 @@ export class EventEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255 })
aggregate_type: string;
/**
* 链路追踪ID
*/
@Column({ type: 'varchar', length: 128, nullable: true })
trace_id: string | null;
/**
* 事件数据JSON格式
*/

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TaskQueueAdapterType, EventBusAdapterType } from './queueTypes';
export interface QueueConfig {
@@ -28,131 +29,102 @@ export interface QueueConfig {
export class QueueFactoryService {
private readonly logger = new Logger(QueueFactoryService.name);
// 使用固定配置,避免硬编码
private readonly defaultConfig: QueueConfig = {
taskAdapter: TaskQueueAdapterType.DATABASE_OUTBOX,
eventAdapter: EventBusAdapterType.DATABASE_OUTBOX,
redis: {
host: 'localhost',
port: 6379,
password: '',
db: 0,
},
queue: {
removeOnComplete: 100,
removeOnFail: 50,
defaultAttempts: 3,
backoffDelay: 2000,
},
kafka: {
clientId: 'wwjcloud-backend',
brokers: ['localhost:9092'],
groupId: 'wwjcloud-group',
topicPrefix: 'domain-events',
},
};
constructor(private readonly config: ConfigService) {}
// 添加适配器属性
private readonly taskQueueAdapter = 'database-outbox';
private readonly eventBusAdapter = 'database-outbox';
private getConfig(): QueueConfig {
return {
taskAdapter: (this.config.get<string>('queue.taskAdapter') as TaskQueueAdapterType) || TaskQueueAdapterType.DATABASE_OUTBOX,
eventAdapter: (this.config.get<string>('queue.eventAdapter') as EventBusAdapterType) || EventBusAdapterType.DATABASE_OUTBOX,
redis: {
host: this.config.get<string>('redis.host'),
port: Number(this.config.get<number>('redis.port')),
password: this.config.get<string>('redis.password') || '',
db: Number(this.config.get<number>('redis.db') || 0),
},
queue: {
removeOnComplete: Number(this.config.get<number>('queue.removeOnComplete') || 100),
removeOnFail: Number(this.config.get<number>('queue.removeOnFail') || 50),
defaultAttempts: Number(this.config.get<number>('queue.defaultAttempts') || 3),
backoffDelay: Number(this.config.get<number>('queue.backoffDelay') || 2000),
},
kafka: {
clientId: this.config.get<string>('kafka.clientId'),
brokers: this.config.get<string[]>('kafka.brokers'),
groupId: this.config.get<string>('kafka.groupId') || 'wwjcloud-group',
topicPrefix: this.config.get<string>('kafka.topicPrefix') || 'domain-events',
},
} as QueueConfig;
}
/**
* 获取任务队列配置
*/
getTaskQueueConfig(): Partial<QueueConfig> {
const cfg = this.getConfig();
return {
taskAdapter: this.defaultConfig.taskAdapter,
redis: this.defaultConfig.redis,
queue: this.defaultConfig.queue,
taskAdapter: cfg.taskAdapter,
redis: cfg.redis,
queue: cfg.queue,
};
}
/**
* 获取事件总线配置
*/
getEventBusConfig(): Partial<QueueConfig> {
const cfg = this.getConfig();
return {
eventAdapter: this.defaultConfig.eventAdapter,
redis: this.defaultConfig.redis,
kafka: this.defaultConfig.kafka,
eventAdapter: cfg.eventAdapter,
redis: cfg.redis,
kafka: cfg.kafka,
};
}
/**
* 获取完整配置
*/
getFullConfig(): QueueConfig {
return { ...this.defaultConfig };
return this.getConfig();
}
/**
* 获取 Redis 配置
*/
getRedisConfig() {
return this.defaultConfig.redis;
return this.getConfig().redis;
}
/**
* 获取队列配置
*/
getQueueConfig() {
return this.defaultConfig.queue;
return this.getConfig().queue;
}
/**
* 获取 Kafka 配置
*/
getKafkaConfig() {
return this.defaultConfig.kafka;
return this.getConfig().kafka;
}
/**
* 获取任务队列适配器类型
*/
getTaskAdapterType(): TaskQueueAdapterType {
return this.defaultConfig.taskAdapter;
return this.getConfig().taskAdapter;
}
/**
* 获取事件总线适配器类型
*/
getEventAdapterType(): EventBusAdapterType {
return this.defaultConfig.eventAdapter;
return this.getConfig().eventAdapter;
}
/**
* 验证配置
*/
validateConfig(config: Partial<QueueConfig>): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
// 验证 Redis 配置
if (config.redis) {
if (!config.redis.host) {
errors.push('Redis host is required');
}
if (config.redis.port < 1 || config.redis.port > 65535) {
if (config.redis.port === undefined || config.redis.port < 1 || config.redis.port > 65535) {
errors.push('Redis port must be between 1 and 65535');
}
}
// 验证队列配置
if (config.queue) {
if (config.queue.removeOnComplete < 0) {
if ((config.queue.removeOnComplete ?? 0) < 0) {
errors.push('removeOnComplete must be non-negative');
}
if (config.queue.removeOnFail < 0) {
if ((config.queue.removeOnFail ?? 0) < 0) {
errors.push('removeOnFail must be non-negative');
}
if (config.queue.defaultAttempts < 1) {
if ((config.queue.defaultAttempts ?? 0) < 1) {
errors.push('defaultAttempts must be at least 1');
}
if (config.queue.backoffDelay < 0) {
if ((config.queue.backoffDelay ?? 0) < 0) {
errors.push('backoffDelay must be non-negative');
}
}
// 验证 Kafka 配置
if (config.kafka) {
if (!config.kafka.clientId) {
errors.push('Kafka clientId is required');
@@ -171,33 +143,27 @@ export class QueueFactoryService {
};
}
/**
* 获取配置摘要
*/
getConfigSummary(): Record<string, any> {
const cfg = this.getConfig();
return {
taskAdapter: this.defaultConfig.taskAdapter,
eventAdapter: this.defaultConfig.eventAdapter,
taskAdapter: cfg.taskAdapter,
eventAdapter: cfg.eventAdapter,
redis: {
host: this.defaultConfig.redis.host,
port: this.defaultConfig.redis.port,
db: this.defaultConfig.redis.db,
},
queue: this.defaultConfig.queue,
kafka: {
clientId: this.defaultConfig.kafka.clientId,
brokers: this.defaultConfig.kafka.brokers,
groupId: this.defaultConfig.kafka.groupId,
topicPrefix: this.defaultConfig.kafka.topicPrefix,
host: cfg.redis.host,
port: cfg.redis.port,
db: cfg.redis.db,
},
queue: cfg.queue,
kafka: cfg.kafka,
};
}
getTaskQueueProvider() {
return this.taskQueueAdapter;
// 对外暴露适配器标识可供装配
return this.getConfig().taskAdapter;
}
getEventBusProvider() {
return this.eventBusAdapter;
return this.getConfig().eventAdapter;
}
}

View File

@@ -1,203 +1,35 @@
import { Injectable, OnModuleDestroy, Inject } from '@nestjs/common';
import { ITaskQueueProvider, ITaskQueue, TaskJobOptions, TaskProcessor, TaskJob } from '@wwjCore/interfaces/queue.interface';
import type { Queue, QueueOptions } from 'bull';
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ITaskQueueProvider, TaskJobOptions, TaskProcessor, TaskJob, ITaskQueue } from '@wwjCore/interfaces/queue.interface';
/**
* 基于Redis的任务队列提供者实现
* 使用Bull/BullMQ实现高性能任务队列
* Deprecated: 该实现基于 bull已统一迁移到 BullMQ见 vendor/queue/bullmq.provider.ts
* 保留文件以兼容旧引用,但不再在工厂或装配中被选择。
*/
@Injectable()
export class RedisTaskQueueProvider implements ITaskQueueProvider, OnModuleDestroy {
private readonly queues = new Map<string, RedisTaskQueue>();
constructor(
@Inject('REDIS_QUEUE_OPTIONS')
private readonly options: Partial<QueueOptions> = {},
) {}
getQueue(name: string): ITaskQueue {
let queue = this.queues.get(name);
if (!queue) {
queue = new RedisTaskQueue(name, this.options);
this.queues.set(name, queue);
}
return queue;
}
async addJob<T = any>(queueName: string, jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
const queue = this.getQueue(queueName);
await queue.addJob(jobName, data, options);
}
async process<T = any>(queueName: string, processor: TaskProcessor<T>): Promise<void> {
const queue = this.getQueue(queueName);
await queue.process(processor);
}
async getQueueStatus(queueName: string): Promise<any> {
const queue = this.getQueue(queueName);
return queue.getStats();
}
async pause(queueName: string): Promise<void> {
const queue = this.getQueue(queueName);
await queue.pause();
}
async resume(queueName: string): Promise<void> {
const queue = this.getQueue(queueName);
await queue.resume();
}
async healthCheck(): Promise<boolean> {
try {
// 检查所有队列的健康状态
for (const queue of this.queues.values()) {
const stats = await queue.getStats();
if (!stats) return false;
}
return true;
} catch (error) {
return false;
}
}
async close(): Promise<void> {
for (const queue of this.queues.values()) {
await queue.close();
}
this.queues.clear();
}
async onModuleDestroy() {
await this.close();
}
}
/**
* Redis任务队列实现
*/
class RedisTaskQueue implements ITaskQueue {
private readonly queue: Queue;
private readonly processors = new Map<string, TaskProcessor>();
constructor(name: string, options: Partial<QueueOptions>) {
const Bull = require('bull');
this.queue = new Bull(name, {
...options,
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
...options.defaultJobOptions,
},
}) as Queue;
// 设置全局处理器
this.queue.process('*', async (job) => {
const processor = this.processors.get(job.name);
if (!processor) {
throw new Error(`No processor found for job type: ${job.name}`);
}
const taskJob: TaskJob = {
id: job.id.toString(),
type: job.name,
data: job.data,
attemptsMade: job.attemptsMade,
timestamp: job.timestamp,
};
return await processor(taskJob);
});
// 错误处理
this.queue.on('failed', (job, err) => {
console.error(`Task ${job.name}:${job.id} failed:`, err);
});
this.queue.on('completed', (job) => {
console.log(`Task ${job.name}:${job.id} completed`);
});
}
async add(jobType: string, payload: any, options: TaskJobOptions = {}): Promise<void> {
const jobOptions: any = {
attempts: options.attempts ?? 3,
backoff: options.backoff
? { type: options.backoff.type, delay: options.backoff.delay }
: undefined,
removeOnComplete: options.removeOnComplete ?? true,
removeOnFail: options.removeOnFail ?? false,
delay: options.delay ?? 0,
priority: options.priority,
};
await this.queue.add(jobType, payload, jobOptions);
}
async addJob(jobType: string, payload: any, options: TaskJobOptions = {}): Promise<void> {
await this.add(jobType, payload, options);
}
process(jobType: string, processor: TaskProcessor): void;
process<T = any>(processor: TaskProcessor<T>): Promise<void>;
process(jobTypeOrProcessor: string | TaskProcessor, processor?: TaskProcessor): void | Promise<void> {
if (typeof jobTypeOrProcessor === 'string') {
// 旧的方法签名process(jobType: string, processor: TaskProcessor): void
this.processors.set(jobTypeOrProcessor, processor!);
} else {
// 新的方法签名process<T = any>(processor: TaskProcessor<T>): Promise<void>
this.processors.set('*', jobTypeOrProcessor);
return Promise.resolve();
}
}
async close(): Promise<void> {
await this.queue.close();
}
/**
* 获取队列统计信息
*/
async getStats() {
const waiting = await this.queue.getWaiting();
const active = await this.queue.getActive();
const completed = await this.queue.getCompleted();
const failed = await this.queue.getFailed();
const delayed = await this.queue.getDelayed();
getQueue(_name: string): ITaskQueue {
return {
waiting: waiting.length,
active: active.length,
completed: completed.length,
failed: failed.length,
delayed: delayed.length,
};
async add(): Promise<void> { throw new Error('Deprecated: Use BullMQ provider instead.'); },
async addJob(): Promise<void> { throw new Error('Deprecated: Use BullMQ provider instead.'); },
async process(): Promise<void> { throw new Error('Deprecated: Use BullMQ provider instead.'); },
async getStats(): Promise<any> { return { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 }; },
async pause(): Promise<void> { /* no-op */ },
async resume(): Promise<void> { /* no-op */ },
async close(): Promise<void> { /* no-op */ },
} as ITaskQueue;
}
/**
* 清理队列
*/
async clean(grace: number = 0, status: 'completed' | 'failed' = 'completed') {
return await this.queue.clean(grace, status);
async addJob<T = any>(_queueName: string, _jobName: string, _data: T, _options?: TaskJobOptions): Promise<void> {
throw new Error('Deprecated: Use BullMQ (vendor/queue/bullmq.provider.ts) instead.');
}
/**
* 暂停队列
*/
async pause() {
return await this.queue.pause();
async process<T = any>(_queueName: string, _processor: TaskProcessor<T>): Promise<void> {
throw new Error('Deprecated: Use BullMQ (vendor/queue/bullmq.provider.ts) instead.');
}
/**
* 恢复队列
*/
async resume() {
return await this.queue.resume();
async getQueueStatus(_queueName: string): Promise<any> {
return { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 };
}
async pause(_queueName: string): Promise<void> {}
async resume(_queueName: string): Promise<void> {}
async healthCheck(): Promise<boolean> { return false; }
async close(): Promise<void> {}
async onModuleDestroy() { await this.close(); }
}

View File

@@ -2,13 +2,19 @@ import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerConfig } from './config/integrations/swaggerConfig';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import multipart from '@fastify/multipart';
import { DbHealthIndicator } from './core/observability/health/indicators/db.indicator';
import { RedisHealthIndicator } from './core/observability/health/indicators/redis.indicator';
import { EventBusHealthIndicator } from './core/observability/health/indicators/eventbus.indicator';
import { QueueHealthIndicator } from './core/observability/health/indicators/queue.indicator';
import { StorageHealthIndicator } from './core/observability/health/indicators/storage.indicator';
import { config } from './config/core/appConfig';
import { SwaggerService } from './config/modules/swagger/swaggerService';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -17,13 +23,14 @@ async function bootstrap() {
{ bufferLogs: true },
);
// 注册 multipart 支持(类型兼容cast any 规避 fastify 多版本类型冲突
// 注册 multipart 支持(参数化自配置中心,仅使用已存在的键
const uploadCfg = config.getUpload();
await (app as any).register(multipart as any, {
limits: {
fieldNameSize: 100,
fieldSize: 1024 * 1024, // 1MB
fieldSize: 1024 * 1024,
fields: 10,
fileSize: 1024 * 1024 * 50, // 50MB 单文件
fileSize: (uploadCfg as any).maxSize ?? 1024 * 1024 * 50,
files: 10,
headerPairs: 2000,
},
@@ -41,12 +48,41 @@ async function bootstrap() {
app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
// 设置分组API文档
SwaggerConfig.setup(app);
// 设置 API 文档(配置中心)
app.get(SwaggerService).setup(app);
const port =
Number(process.env.PORT) ||
(process.env.NODE_ENV === 'development' ? 3001 : 3000);
await app.listen({ port, host: '0.0.0.0' });
// 启动期 fail-fast受配置中心控制
const healthCfg = config.getHealth();
if (healthCfg.startupCheckEnabled) {
await app.init();
const checks: Array<() => Promise<unknown>> = [
() => app.get(DbHealthIndicator).check(),
() => app.get(RedisHealthIndicator).check(),
() => app.get(EventBusHealthIndicator).check(),
() => app.get(QueueHealthIndicator).check(),
() => app.get(StorageHealthIndicator).check(),
];
await Promise.all(checks.map((fn) => fn()));
}
const host = '0.0.0.0';
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
await app.listen(port, host);
try {
const url = await app.getUrl();
const base = url.replace(/\/$/, '');
const logger = app.get(WINSTON_MODULE_NEST_PROVIDER) as any;
const msg = [
`Server started: ${base}`,
`Docs (full): ${base}/api-json`,
`Docs (admin): ${base}/api/admin-json`,
`Docs (frontend): ${base}/api/frontend-json`,
].join(' | ');
logger?.log?.(msg) || console.log(msg);
} catch {
// ignore
}
}
bootstrap();

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AlterEventsAddSiteTrace1757000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS events (
id int NOT NULL AUTO_INCREMENT,
event_id varchar(36) NOT NULL,
event_type varchar(255) NOT NULL,
aggregate_id varchar(255) NOT NULL,
aggregate_type varchar(255) NOT NULL,
site_id bigint NOT NULL DEFAULT 0,
trace_id varchar(128) NULL,
event_version int NOT NULL DEFAULT 1,
event_data text NOT NULL,
occurred_at int NOT NULL,
processed_at int NOT NULL DEFAULT 0,
retry_count int NOT NULL DEFAULT 0,
next_retry_at int NOT NULL DEFAULT 0,
last_error text NULL,
status enum('pending','processing','processed','failed') NOT NULL DEFAULT 'pending',
PRIMARY KEY (id),
UNIQUE KEY uk_events_event_id (event_id),
KEY idx_events_type_processed (event_type, processed_at),
KEY idx_events_site_status (site_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 兼容已存在表,逐列补充
const addColumn = async (name: string, ddl: string) => {
try { await queryRunner.query(`ALTER TABLE events ADD COLUMN ${ddl}`); } catch { /* 已存在忽略 */ }
};
await addColumn('site_id', 'site_id bigint NOT NULL DEFAULT 0');
await addColumn('trace_id', 'trace_id varchar(128) NULL');
await addColumn('event_version', 'event_version int NOT NULL DEFAULT 1');
await addColumn('next_retry_at', 'next_retry_at int NOT NULL DEFAULT 0');
await addColumn('retry_count', 'retry_count int NOT NULL DEFAULT 0');
await addColumn('last_error', 'last_error text NULL');
// 索引
try { await queryRunner.query(`CREATE INDEX idx_events_site_status ON events (site_id, status)`); } catch {}
try { await queryRunner.query(`CREATE INDEX idx_events_type_processed ON events (event_type, processed_at)`); } catch {}
try { await queryRunner.query(`ALTER TABLE events ADD UNIQUE KEY uk_events_event_id (event_id)`); } catch {}
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 仅删除新增索引/列,不删除整表
try { await queryRunner.query(`ALTER TABLE events DROP INDEX idx_events_site_status`); } catch {}
try { await queryRunner.query(`ALTER TABLE events DROP INDEX idx_events_type_processed`); } catch {}
try { await queryRunner.query(`ALTER TABLE events DROP INDEX uk_events_event_id`); } catch {}
const dropColumn = async (name: string) => {
try { await queryRunner.query(`ALTER TABLE events DROP COLUMN ${name}`); } catch {}
};
await dropColumn('site_id');
await dropColumn('trace_id');
await dropColumn('event_version');
await dropColumn('next_retry_at');
await dropColumn('retry_count');
await dropColumn('last_error');
}
}

View File

@@ -3,6 +3,12 @@ import { Injectable, OnModuleDestroy, Inject } from '@nestjs/common';
interface KafkaOptions {
clientId: string;
brokers: string[];
connectionTimeout?: number;
requestTimeout?: number;
retry?: {
initialRetryTime?: number;
retries?: number;
};
}
// 定义 Kafka 相关类型
@@ -24,7 +30,6 @@ export class KafkaProvider implements OnModuleDestroy {
constructor(
@Inject('KAFKA_OPTIONS') private readonly kafkaOptions: KafkaOptions,
) {
// 添加调试日志
console.log('🔍 KafkaProvider 配置:', {
clientId: this.kafkaOptions.clientId,
brokers: this.kafkaOptions.brokers,
@@ -33,28 +38,15 @@ export class KafkaProvider implements OnModuleDestroy {
async ensure() {
if (!this.kafka) {
// 动态导入 kafkajs
const { Kafka } = await import('kafkajs');
// 添加调试日志
console.log('🔍 创建 Kafka 客户端,配置:', {
const options: any = {
clientId: this.kafkaOptions.clientId,
brokers: this.kafkaOptions.brokers,
});
this.kafka = new Kafka({
clientId: this.kafkaOptions.clientId,
brokers: this.kafkaOptions.brokers,
// 连接配置
connectionTimeout: 3000,
// 请求超时
requestTimeout: 30000,
// 重试配置
retry: {
initialRetryTime: 100,
retries: 8,
},
}) as KafkaClient;
};
if (this.kafkaOptions.connectionTimeout !== undefined) options.connectionTimeout = this.kafkaOptions.connectionTimeout;
if (this.kafkaOptions.requestTimeout !== undefined) options.requestTimeout = this.kafkaOptions.requestTimeout;
if (this.kafkaOptions.retry) options.retry = this.kafkaOptions.retry;
this.kafka = new Kafka(options) as KafkaClient;
}
if (!this.producer) {
this.producer = this.kafka.producer();
@@ -75,17 +67,12 @@ export class KafkaProvider implements OnModuleDestroy {
key: key ?? undefined,
value:
typeof message === 'string' ? message : JSON.stringify(message),
headers: {
'x-request-id': process.env.REQUEST_ID || '',
traceparent: process.env.TRACEPARENT || '',
},
},
],
});
} catch (error) {
// 如果发送失败,尝试重新连接
console.error('Kafka publish error:', error);
this.producer = null; // 重置 producer
this.producer = null;
throw error;
}
}
@@ -94,9 +81,7 @@ export class KafkaProvider implements OnModuleDestroy {
if (this.producer) {
try {
await this.producer.disconnect();
} catch {
// 忽略断开连接错误
}
} catch {}
}
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { HttpModule as NestHttpModule } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { HttpAdapter } from './axios.adapter';
/**
@@ -8,14 +9,17 @@ import { HttpAdapter } from './axios.adapter';
*/
@Module({
imports: [
NestHttpModule.register({
timeout: 10000,
maxRedirects: 5,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': 'wwjcloud-nestjs/1.0.0',
},
NestHttpModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
timeout: Number(config.get<number>('http.timeout') || 10000),
maxRedirects: Number(config.get<number>('http.maxRedirects') || 5),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': config.get<string>('http.userAgent') || 'wwjcloud-nestjs/1.0.0',
},
}),
}),
],
providers: [HttpAdapter],

View File

@@ -1,92 +1,147 @@
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import type { Queue, QueueOptions, Job } from 'bull';
import { ITaskQueueProvider, TaskJobOptions, TaskProcessor } from '@wwjCore/interfaces/queue.interface';
import { ITaskQueueProvider, TaskJobOptions, TaskProcessor, ITaskQueue } from '@wwjCore/interfaces/queue.interface';
interface BullMQOptions {
connection: {
host?: string;
port?: number;
password?: string;
db?: number;
};
defaultJobOptions?: Record<string, any>;
}
@Injectable()
export class BullQueueProvider implements ITaskQueueProvider, OnModuleDestroy {
private readonly queues = new Map<string, Queue>();
private readonly queues = new Map<string, any>();
constructor(
@Inject('BULLMQ_OPTIONS')
private readonly options: Partial<QueueOptions> = {},
private readonly options: Partial<BullMQOptions> = {},
) {}
getQueue(name: string): any {
private ensureConnection() {
if (!this.options?.connection) {
throw new Error('BULLMQ_OPTIONS.connection 未配置请在配置中心vendor.module.ts -> BULLMQ_OPTIONS注入 Redis 连接信息');
}
}
private async ensureQueue(name: string): Promise<any> {
let q = this.queues.get(name);
if (!q) {
const Bull = require('bull');
q = new Bull(name, {
...this.options,
}) as Queue;
this.queues.set(name, q);
this.ensureConnection();
const { Queue, Worker } = await import('bullmq');
const conn = this.options.connection! as any;
const queue = new Queue(name, {
connection: conn,
defaultJobOptions: this.options.defaultJobOptions || {},
});
const worker = new Worker(
name,
async (job: any) => {
const processor = (queue as any).__processors?.get(job.name) || (queue as any).__processors?.get('*');
if (!processor) throw new Error(`No processor for job: ${job.name}`);
const taskJob = {
id: String(job.id),
type: job.name,
data: job.data,
attemptsMade: job.attemptsMade,
timestamp: job.timestamp,
};
return await processor(taskJob);
},
{ connection: conn }
);
(queue as any).__worker = worker;
(queue as any).__processors = new Map<string, TaskProcessor<any>>();
this.queues.set(name, queue);
q = queue;
}
return q;
}
public getQueue(name: string): ITaskQueue {
const self = this;
return {
async add(jobType: string, payload: any, options?: TaskJobOptions): Promise<void> {
const q = await self.ensureQueue(name);
const jobOptions: any = {
attempts: options?.attempts,
backoff: options?.backoff ? { type: options.backoff.type, delay: options.backoff.delay } : undefined,
removeOnComplete: options?.removeOnComplete,
removeOnFail: options?.removeOnFail,
delay: options?.delay,
priority: options?.priority,
};
await q.add(jobType, payload, jobOptions);
},
async addJob<T = any>(jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
return this.add(jobName, data, options);
},
async process(jobTypeOrProcessor: any, maybeProcessor?: any): Promise<void> {
const q = await self.ensureQueue(name);
const map = (q as any).__processors as Map<string, TaskProcessor<any>>;
if (typeof jobTypeOrProcessor === 'string') {
map.set(jobTypeOrProcessor, maybeProcessor);
} else {
map.set('*', jobTypeOrProcessor);
}
},
async getStats(): Promise<any> {
const q = await self.ensureQueue(name);
const counts = await q.getJobCounts();
return {
waiting: counts.waiting || 0,
active: counts.active || 0,
completed: counts.completed || 0,
failed: counts.failed || 0,
delayed: counts.delayed || 0,
};
},
async pause(): Promise<void> {
const q = await self.ensureQueue(name);
await q.pause();
},
async resume(): Promise<void> {
const q = await self.ensureQueue(name);
await q.resume();
},
async close(): Promise<void> {
const q = await self.ensureQueue(name);
const worker = (q as any).__worker;
if (worker) await worker.close();
await q.close();
},
} as ITaskQueue;
}
async addJob<T = any>(queueName: string, jobName: string, data: T, options?: TaskJobOptions): Promise<void> {
const queue = this.getQueue(queueName);
const jobOptions: any = {
attempts: options?.attempts ?? 3,
backoff: options?.backoff
? { type: options.backoff.type, delay: options.backoff.delay }
: undefined,
removeOnComplete: options?.removeOnComplete ?? true,
removeOnFail: options?.removeOnFail ?? false,
delay: options?.delay ?? 0,
priority: options?.priority,
};
await queue.add(jobName, data, jobOptions);
return this.getQueue(queueName).addJob(jobName, data, options);
}
async process<T = any>(queueName: string, processor: TaskProcessor<T>): Promise<void> {
const queue = this.getQueue(queueName);
queue.process('*', async (job: Job<T>) => {
const taskJob = {
id: job.id.toString(),
type: job.name,
data: job.data,
attemptsMade: job.attemptsMade,
timestamp: job.timestamp,
};
return await processor(taskJob);
});
return this.getQueue(queueName).process(processor as any);
}
async getQueueStatus(queueName: string): Promise<any> {
const queue = this.getQueue(queueName);
const waiting = await queue.getWaiting();
const active = await queue.getActive();
const completed = await queue.getCompleted();
const failed = await queue.getFailed();
const delayed = await queue.getDelayed();
return {
waiting: waiting.length,
active: active.length,
completed: completed.length,
failed: failed.length,
delayed: delayed.length,
};
return this.getQueue(queueName).getStats();
}
async pause(queueName: string): Promise<void> {
const queue = this.getQueue(queueName);
await queue.pause();
return this.getQueue(queueName).pause();
}
async resume(queueName: string): Promise<void> {
const queue = this.getQueue(queueName);
await queue.resume();
return this.getQueue(queueName).resume();
}
async healthCheck(): Promise<boolean> {
try {
for (const queue of this.queues.values()) {
const stats = await this.getQueueStatus(queue.name);
if (!stats) return false;
for (const name of this.queues.keys()) {
await this.getQueue(name).getStats();
}
return true;
} catch (error) {
} catch {
return false;
}
}
@@ -98,6 +153,8 @@ export class BullQueueProvider implements ITaskQueueProvider, OnModuleDestroy {
async onModuleDestroy() {
for (const q of this.queues.values()) {
try {
const worker = (q as any).__worker;
if (worker) await worker.close();
await q.close();
} catch {}
}

View File

@@ -5,6 +5,7 @@ import { BullQueueProvider } from './queue/bullmq.provider';
import { KafkaProvider } from './event/kafka.provider';
import { storageProviders } from './storage/providers/storage.provider';
import { SysConfig } from '../common/settings/entities/sys-config.entity';
import { RedisProvider } from './redis/redis.provider';
@Module({
imports: [
@@ -15,7 +16,7 @@ import { SysConfig } from '../common/settings/entities/sys-config.entity';
provide: 'BULLMQ_OPTIONS',
useFactory: (configService: ConfigService) => {
const options = {
redis: {
connection: {
host: configService.get('redis.host'),
port: configService.get('redis.port'),
password: configService.get('redis.password'),
@@ -49,8 +50,9 @@ import { SysConfig } from '../common/settings/entities/sys-config.entity';
},
BullQueueProvider,
KafkaProvider,
RedisProvider,
...storageProviders,
],
exports: [BullQueueProvider, KafkaProvider, ...storageProviders],
exports: [BullQueueProvider, KafkaProvider, RedisProvider, ...storageProviders],
})
export class VendorModule {}