feat: 修复抖音图片审核失败并设计统一异步架构
解决抖音图片审核"无效图片URL"问题,并设计统一异步架构方案: ## 核心修复 - 增强 validateImageUrl 方法,支持文件扩展名 + Content-Type 双重验证 - 修复抖音审核失败问题:binary/octet-stream 响应头导致的验证失败 ## 架构设计 - 设计统一异步内容审核架构,抹平平台差异(同步/异步) - 创建 EnhancedBaseContentAdapter 统一接口 - 实现平台适配器模式,WeChat同步转异步,Douyin原生异步 - 添加回调驱动的模板执行流程 ## 数据库增强 - 新增 taskId 字段关联审核任务和模板执行 - 添加 PENDING_AUDIT 状态支持异步审核流程 - 创建 MySQL 优化的数据库迁移脚本 ## 文档完善 - 更新内容审核设计文档,包含详细序列图 - 创建 5 阶段升级文档,含 MySQL 专项优化 - 提供完整的向后兼容迁移方案 ## 测试改进 - 更新单元测试,修复 validateStatus 参数 - 增强错误处理和边界情况测试覆盖
This commit is contained in:
10
.env.example
10
.env.example
@@ -24,3 +24,13 @@ WECHAT_APP_SECRET=your_wechat_app_secret
|
||||
# 抖音小程序配置
|
||||
BYTEDANCE_APP_ID=your_bytedance_app_id
|
||||
BYTEDANCE_APP_SECRET=your_bytedance_app_secret
|
||||
|
||||
# 内容审核配置
|
||||
# 图片审核回调地址(重要:抖音审核完成后会调用此接口)
|
||||
AUDIT_CALLBACK_URL=https://your-domain.com/api/v1/content-moderation/callback
|
||||
|
||||
# 抖音内容审核API地址
|
||||
BYTEDANCE_AUDIT_API_URL=https://developer.toutiao.com/api/apps/v2/content/audit/image
|
||||
|
||||
# 微信内容审核API地址
|
||||
WECHAT_AUDIT_API_URL=https://api.weixin.qq.com/wxa/img_sec_check
|
||||
@@ -1,39 +1,87 @@
|
||||
# 图片内容审核检测功能设计方案
|
||||
# 图片内容审核检测功能设计方案 (统一异步架构)
|
||||
|
||||
## 🎯 功能概述
|
||||
|
||||
基于现有的平台适配器和模板管理系统架构,设计多平台图片内容审核检测功能,支持抖音、微信等主流平台的图片安全检测API,确保用户上传的图片内容合规。
|
||||
基于现有的平台适配器和模板管理系统架构,设计多平台图片内容审核检测功能,支持抖音、微信等主流平台的图片安全检测API。
|
||||
|
||||
**🚀 核心创新**:采用**统一异步架构**,通过平台差异抹平技术,将同步平台(如微信)适配为异步模式,实现所有平台的一致异步体验。
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 核心设计理念
|
||||
|
||||
遵循项目现有的**平台适配器模式** + **混合式架构**设计:
|
||||
**统一异步 + 平台差异抹平**:
|
||||
|
||||
```
|
||||
Content Moderation 层次结构:
|
||||
┌─────────────────────────────────────┐
|
||||
│ Unified Content Service │ ← 统一内容审核服务接口
|
||||
├─────────────────────────────────────┤
|
||||
│ Content Moderation Adapter Factory │ ← 审核适配器工厂(策略模式)
|
||||
├─────────────────────────────────────┤
|
||||
│ DouyinAdapter │ WechatAdapter │... │ ← 具体平台适配器
|
||||
├─────────────────────────────────────┤
|
||||
│ BaseContentAdapter (抽象基类) │ ← 通用审核适配器接口
|
||||
├─────────────────────────────────────┤
|
||||
│ External Platform APIs │ ← 抖音/微信审核API
|
||||
└─────────────────────────────────────┘
|
||||
Enhanced Content Moderation 架构:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Template Execution Controller │ ← 模板执行控制器
|
||||
│ (统一异步模式) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Unified Content Service │ ← 统一内容审核服务
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Content Moderation Adapter Factory │ ← 审核适配器工厂
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Enhanced Base Content Adapter │ ← 增强基础适配器
|
||||
│ (平台差异抹平层) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Enhanced │ Enhanced │ Douyin │ ← 平台适配器层
|
||||
│ Wechat │ Future │ Adapter │
|
||||
│ Adapter │ Platform │ (原生异步) │
|
||||
│ (同步→异步) │ Adapters │ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 微信同步API │ 其他平台API │ 抖音异步API │ ← 外部平台API
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📁 模块结构
|
||||
### 🎯 统一异步执行流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant TC as Template Controller
|
||||
participant CS as Content Service
|
||||
participant WA as Wechat Adapter
|
||||
participant DA as Douyin Adapter
|
||||
participant DB as Database
|
||||
|
||||
U->>TC: POST /templates/code/xxx/execute
|
||||
TC->>CS: auditImage()
|
||||
CS->>WA: auditImage() (同步平台)
|
||||
CS->>DA: auditImage() (异步平台)
|
||||
|
||||
Note over WA,DA: 🎯 统一返回 PROCESSING 状态
|
||||
WA-->>CS: {status: PROCESSING}
|
||||
DA-->>CS: {status: PROCESSING}
|
||||
CS-->>TC: {status: PROCESSING}
|
||||
|
||||
TC->>DB: 创建执行记录 (PENDING_AUDIT)
|
||||
TC-->>U: 返回 executionId 和状态
|
||||
|
||||
Note over WA: 同步平台立即触发回调
|
||||
WA->>WA: setImmediate(handleCallback)
|
||||
WA->>TC: handleAuditComplete()
|
||||
|
||||
Note over DA: 异步平台通过外部回调
|
||||
DA-->>TC: 外部回调触发 handleAuditComplete()
|
||||
|
||||
TC->>DB: 更新状态并执行模板
|
||||
|
||||
U->>TC: 轮询 /executions/{id}/status
|
||||
TC-->>U: 返回最新状态
|
||||
```
|
||||
|
||||
## 📁 模块结构 (增强版)
|
||||
|
||||
```typescript
|
||||
src/
|
||||
├── content-moderation/
|
||||
│ ├── adapters/
|
||||
│ │ ├── base-content.adapter.ts // 基础审核适配器抽象类
|
||||
│ │ ├── douyin-content.adapter.ts // 抖音审核适配器
|
||||
│ │ ├── wechat-content.adapter.ts // 微信审核适配器
|
||||
│ │ ├── base-content.adapter.ts // 原始基础适配器
|
||||
│ │ ├── enhanced-base-content.adapter.ts // 🆕 增强基础适配器(平台差异抹平)
|
||||
│ │ ├── douyin-content.adapter.ts // 抖音审核适配器(原生异步)
|
||||
│ │ ├── wechat-content.adapter.ts // 微信审核适配器(原始同步版本)
|
||||
│ │ ├── enhanced-wechat-content.adapter.ts // 🆕 增强微信适配器(同步→异步)
|
||||
│ │ └── index.ts
|
||||
│ ├── services/
|
||||
│ │ ├── content-adapter.factory.ts // 审核适配器工厂
|
||||
@@ -41,18 +89,100 @@ src/
|
||||
│ ├── interfaces/
|
||||
│ │ ├── content-moderation.interface.ts // 内容审核接口定义
|
||||
│ │ └── audit-result.interface.ts // 审核结果接口
|
||||
│ ├── controllers/
|
||||
│ │ └── content-moderation.controller.ts // 内容审核控制器
|
||||
│ ├── dto/
|
||||
│ │ ├── image-audit.dto.ts // 图片审核DTO
|
||||
│ │ └── audit-response.dto.ts // 审核响应DTO
|
||||
│ ├── entities/
|
||||
│ │ └── content-audit-log.entity.ts // 审核日志实体
|
||||
│ ├── guards/
|
||||
│ │ └── content-audit.guard.ts // 内容审核守卫
|
||||
│ └── guards/
|
||||
│ └── content-audit.guard.ts // 内容审核守卫
|
||||
├── controllers/
|
||||
│ ├── template.controller.ts // 原始模板控制器
|
||||
│ └── enhanced-template.controller.ts // 🆕 增强模板控制器(统一异步)
|
||||
└── entities/
|
||||
└── template-execution.entity.ts // 🔄 模板执行实体(扩展状态枚举)
|
||||
│ └── content-moderation.module.ts // 内容审核模块
|
||||
```
|
||||
|
||||
## 🔧 核心接口定义
|
||||
|
||||
### 🆕 增强执行状态枚举
|
||||
|
||||
```typescript
|
||||
// 扩展原有的 ExecutionStatus 枚举
|
||||
enum ExecutionStatus {
|
||||
PENDING = 'pending', // 等待中
|
||||
PENDING_AUDIT = 'pending_audit', // 🆕 待审核(关键新状态)
|
||||
AUDIT_FAILED = 'audit_failed', // 🆕 审核失败
|
||||
PROCESSING = 'processing', // 执行中
|
||||
COMPLETED = 'completed', // 已完成
|
||||
FAILED = 'failed', // 执行失败
|
||||
}
|
||||
```
|
||||
|
||||
### 🆕 增强模板执行实体
|
||||
|
||||
```typescript
|
||||
// 扩展 TemplateExecutionEntity
|
||||
@Entity('template_executions')
|
||||
export class TemplateExecutionEntity {
|
||||
// ... 原有字段
|
||||
|
||||
@Column({ name: 'audit_task_id', nullable: true })
|
||||
auditTaskId?: string; // 🆕 关联审核任务ID
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ExecutionStatus,
|
||||
default: ExecutionStatus.PENDING_AUDIT // 🆕 默认状态改为待审核
|
||||
})
|
||||
status: ExecutionStatus;
|
||||
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
### 🎯 平台差异抹平接口
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 增强基础适配器接口
|
||||
*/
|
||||
export abstract class EnhancedBaseContentAdapter {
|
||||
/** 标识平台特性 */
|
||||
abstract readonly isSyncPlatform: boolean;
|
||||
|
||||
/** 统一异步审核接口 - 所有平台都返回 PROCESSING 状态 */
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
// 1. 调用平台API
|
||||
const platformResult = await this.callPlatformAuditAPI(auditData);
|
||||
|
||||
// 2. 统一返回处理中状态
|
||||
const unifiedResult = {
|
||||
taskId: auditData.taskId,
|
||||
status: AuditStatus.PROCESSING, // 🎯 关键:统一异步状态
|
||||
conclusion: AuditConclusion.UNCERTAIN,
|
||||
// ...
|
||||
};
|
||||
|
||||
// 3. 同步平台立即触发回调
|
||||
if (this.isSyncPlatform) {
|
||||
setImmediate(() => this.simulateCallback(platformResult));
|
||||
}
|
||||
|
||||
return unifiedResult;
|
||||
}
|
||||
|
||||
/** 平台特定API调用 */
|
||||
protected abstract callPlatformAuditAPI(auditData: ImageAuditRequest): Promise<any>;
|
||||
|
||||
/** 格式化回调数据 */
|
||||
protected abstract formatCallbackData(platformResult: any): any;
|
||||
}
|
||||
```
|
||||
|
||||
### 内容审核适配器接口
|
||||
|
||||
```typescript
|
||||
@@ -137,7 +267,148 @@ export interface AuditDetail {
|
||||
}
|
||||
```
|
||||
|
||||
## 🏛️ 基础适配器抽象类
|
||||
## 🚀 统一异步架构核心设计
|
||||
|
||||
### 🎯 设计原则
|
||||
|
||||
1. **平台无关性**:业务逻辑不关心底层平台是同步还是异步
|
||||
2. **统一体验**:所有平台都提供一致的异步执行体验
|
||||
3. **状态驱动**:通过状态管理驱动整个执行流程
|
||||
4. **回调集成**:审核完成后自动触发模板执行
|
||||
|
||||
### 🔄 关键执行流程
|
||||
|
||||
```typescript
|
||||
// 🎯 统一异步执行模式
|
||||
class EnhancedTemplateController {
|
||||
async executeTemplateByCode(code: string, body: { imageUrl: string }) {
|
||||
// 1. 提交审核(所有平台都返回 PROCESSING)
|
||||
const auditResult = await this.contentService.auditImage(platform, auditData);
|
||||
|
||||
// 2. 创建执行记录(PENDING_AUDIT 状态)
|
||||
const execution = await this.createExecution({
|
||||
auditTaskId: auditResult.taskId,
|
||||
status: ExecutionStatus.PENDING_AUDIT, // 🎯 关键状态
|
||||
// ...
|
||||
});
|
||||
|
||||
// 3. 立即返回,不等待审核结果
|
||||
return { executionId: execution.id, status: 'pending_audit' };
|
||||
}
|
||||
|
||||
// 🎯 审核完成回调 - 无论同步异步平台都会调用
|
||||
async handleAuditComplete(auditTaskId: string, auditResult: ContentAuditResult) {
|
||||
const execution = await this.findByAuditTaskId(auditTaskId);
|
||||
|
||||
if (auditResult.conclusion === AuditConclusion.PASS) {
|
||||
// 审核通过,开始执行模板
|
||||
await this.startTemplateExecution(execution);
|
||||
} else {
|
||||
// 审核失败,更新状态
|
||||
await this.updateStatus(execution.id, ExecutionStatus.AUDIT_FAILED);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🔧 平台差异抹平机制
|
||||
|
||||
```typescript
|
||||
// 🎯 微信适配器:同步 → 异步
|
||||
class EnhancedWechatAdapter extends EnhancedBaseContentAdapter {
|
||||
readonly isSyncPlatform = true; // 标识同步平台
|
||||
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
// 1. 调用微信同步API
|
||||
const syncResult = await this.callWechatSyncAPI(auditData);
|
||||
|
||||
// 2. 统一返回 PROCESSING 状态
|
||||
const processingResult = {
|
||||
taskId: auditData.taskId,
|
||||
status: AuditStatus.PROCESSING, // 🎯 伪装成异步
|
||||
conclusion: AuditConclusion.UNCERTAIN,
|
||||
};
|
||||
|
||||
// 3. 立即触发"伪异步"回调
|
||||
setImmediate(async () => {
|
||||
const callbackData = this.formatCallbackData(syncResult);
|
||||
await this.handleAuditCallback(callbackData);
|
||||
});
|
||||
|
||||
return processingResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 抖音适配器:原生异步
|
||||
class DouyinAdapter extends BaseContentAdapter {
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
// 直接调用异步API,通过外部回调处理结果
|
||||
const response = await this.callDouyinAsyncAPI(auditData);
|
||||
return {
|
||||
taskId: response.task_id,
|
||||
status: AuditStatus.PROCESSING, // 原生异步状态
|
||||
conclusion: AuditConclusion.UNCERTAIN,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 📊 状态流转图
|
||||
|
||||
```
|
||||
用户提交
|
||||
↓
|
||||
PENDING_AUDIT (待审核)
|
||||
↓
|
||||
[审核处理中...]
|
||||
↓
|
||||
审核完成回调
|
||||
├─ PASS → PROCESSING (开始执行模板)
|
||||
│ ↓
|
||||
│ COMPLETED (执行完成)
|
||||
│
|
||||
└─ REJECT → AUDIT_FAILED (审核失败)
|
||||
```
|
||||
|
||||
### 🎨 前端集成示例
|
||||
|
||||
```javascript
|
||||
// 前端统一异步体验
|
||||
async function executeTemplate(code, imageUrl) {
|
||||
// 1. 提交任务
|
||||
const response = await api.post(`/enhanced/templates/code/${code}/execute`, { imageUrl });
|
||||
const { executionId } = response.data;
|
||||
|
||||
// 2. 轮询状态
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
const status = await api.get(`/enhanced/templates/executions/${executionId}/status`);
|
||||
|
||||
switch (status.data.status) {
|
||||
case 'pending_audit':
|
||||
setTimeout(poll, 2000); // 继续轮询
|
||||
break;
|
||||
case 'audit_failed':
|
||||
reject(new Error(status.data.errorMessage));
|
||||
break;
|
||||
case 'processing':
|
||||
setTimeout(poll, 3000); // 继续轮询
|
||||
break;
|
||||
case 'completed':
|
||||
resolve(status.data);
|
||||
break;
|
||||
case 'failed':
|
||||
reject(new Error(status.data.errorMessage));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 🏛️ 增强基础适配器实现
|
||||
|
||||
```typescript
|
||||
// content-moderation/adapters/base-content.adapter.ts
|
||||
@@ -1025,6 +1296,91 @@ export class ImageAuditDto {
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 环境变量配置 (更新版)
|
||||
|
||||
### 📋 必需环境变量
|
||||
|
||||
```bash
|
||||
# .env 文件配置
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=mysql-server-host
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=username
|
||||
DB_PASSWORD=password
|
||||
DB_DATABASE=database_name
|
||||
|
||||
# 应用配置
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APP_ID=wxb51f0b0c3aad7cdf
|
||||
WECHAT_APP_SECRET=your_wechat_app_secret
|
||||
|
||||
# 抖音小程序配置
|
||||
BYTEDANCE_APP_ID=ttbfd9c96420ec8f8201
|
||||
BYTEDANCE_APP_SECRET=04a026867fbd1ba52174c7c21d94cbc7361ec378
|
||||
|
||||
# 🆕 内容审核配置(关键配置)
|
||||
# 图片审核回调地址(重要:抖音审核完成后会调用此接口)
|
||||
AUDIT_CALLBACK_URL=https://api.bowongai.com/api/v1/content-moderation/bytedance/callback
|
||||
|
||||
# 抖音内容审核API地址
|
||||
BYTEDANCE_AUDIT_API_URL=https://developer.toutiao.com/api/apps/v2/content/audit/image
|
||||
|
||||
# 微信内容审核API地址
|
||||
WECHAT_AUDIT_API_URL=https://api.weixin.qq.com/wxa/img_sec_check
|
||||
|
||||
# N8N配置
|
||||
N8N_WEBHOOK_URL=https://n8n.bowongai.com/webhook/f1487ca8-bc49-4994-ba82-e2bcb95931f9
|
||||
```
|
||||
|
||||
### 🔧 配置验证
|
||||
|
||||
```typescript
|
||||
// config/content-moderation.config.ts
|
||||
export const contentModerationConfig = () => ({
|
||||
audit: {
|
||||
callback: {
|
||||
url: process.env.AUDIT_CALLBACK_URL,
|
||||
timeout: parseInt(process.env.AUDIT_CALLBACK_TIMEOUT) || 30000,
|
||||
},
|
||||
bytedance: {
|
||||
appId: process.env.BYTEDANCE_APP_ID,
|
||||
appSecret: process.env.BYTEDANCE_APP_SECRET,
|
||||
apiUrl: process.env.BYTEDANCE_AUDIT_API_URL ||
|
||||
'https://developer.toutiao.com/api/apps/v2/content/audit/image',
|
||||
},
|
||||
wechat: {
|
||||
appId: process.env.WECHAT_APP_ID,
|
||||
appSecret: process.env.WECHAT_APP_SECRET,
|
||||
apiUrl: process.env.WECHAT_AUDIT_API_URL ||
|
||||
'https://api.weixin.qq.com/wxa/img_sec_check',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 配置验证
|
||||
export function validateConfig() {
|
||||
const required = [
|
||||
'BYTEDANCE_APP_ID',
|
||||
'BYTEDANCE_APP_SECRET',
|
||||
'AUDIT_CALLBACK_URL',
|
||||
'WECHAT_APP_ID',
|
||||
'WECHAT_APP_SECRET',
|
||||
];
|
||||
|
||||
const missing = required.filter(key => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 模块配置
|
||||
|
||||
```typescript
|
||||
@@ -1249,3 +1605,165 @@ AUDIT_CALLBACK_URL=https://your-domain.com/api/v1/content-moderation/callback
|
||||
WECHAT_APP_ID=your_wechat_app_id
|
||||
WECHAT_APP_SECRET=your_wechat_app_secret
|
||||
```
|
||||
|
||||
## 🚀 部署指南
|
||||
|
||||
### 📋 部署清单
|
||||
|
||||
1. **环境变量配置**:
|
||||
- ✅ 配置所有必需的环境变量
|
||||
- ✅ 确保回调URL可被外部访问
|
||||
- ✅ 验证API密钥的有效性
|
||||
|
||||
2. **数据库迁移**:
|
||||
```bash
|
||||
# 运行数据库迁移
|
||||
npm run migration:run
|
||||
|
||||
# 或手动执行SQL
|
||||
ALTER TABLE template_executions ADD COLUMN audit_task_id VARCHAR(255);
|
||||
ALTER TABLE template_executions MODIFY COLUMN status ENUM('pending','pending_audit','audit_failed','processing','completed','failed') DEFAULT 'pending_audit';
|
||||
```
|
||||
|
||||
3. **SSL证书配置**:
|
||||
- 确保回调URL使用HTTPS
|
||||
- 配置有效的SSL证书
|
||||
|
||||
4. **防火墙配置**:
|
||||
- 开放回调接口端口
|
||||
- 允许抖音服务器IP访问
|
||||
|
||||
### 🔧 启动步骤
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
npm install
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件
|
||||
|
||||
# 3. 数据库迁移
|
||||
npm run migration:run
|
||||
|
||||
# 4. 启动应用
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
### 🧪 测试验证
|
||||
|
||||
```bash
|
||||
# 1. 测试抖音审核适配器
|
||||
curl -X POST https://your-domain.com/api/v1/content-moderation/bytedance/audit-image \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{"imageUrl": "https://example.com/test.jpg"}'
|
||||
|
||||
# 2. 测试统一异步模板执行
|
||||
curl -X POST https://your-domain.com/enhanced/templates/code/photo_restore_v1/execute \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-d '{"imageUrl": "https://example.com/test.jpg"}'
|
||||
|
||||
# 3. 查询执行状态
|
||||
curl -X GET https://your-domain.com/enhanced/templates/executions/123/status \
|
||||
-H "Authorization: Bearer your-token"
|
||||
```
|
||||
|
||||
## 📊 监控和告警
|
||||
|
||||
### 🔍 关键指标
|
||||
|
||||
1. **审核性能指标**:
|
||||
- 审核响应时间
|
||||
- 审核成功率
|
||||
- 回调处理成功率
|
||||
|
||||
2. **执行状态分布**:
|
||||
- PENDING_AUDIT 数量
|
||||
- AUDIT_FAILED 比例
|
||||
- 整体执行成功率
|
||||
|
||||
3. **错误监控**:
|
||||
- API调用失败
|
||||
- 回调处理异常
|
||||
- 数据库连接异常
|
||||
|
||||
### 📈 监控配置
|
||||
|
||||
```typescript
|
||||
// metrics/audit.metrics.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectMetric } from '@nestjs/prometheus';
|
||||
import { Counter, Histogram, Gauge } from 'prom-client';
|
||||
|
||||
@Injectable()
|
||||
export class AuditMetrics {
|
||||
constructor(
|
||||
@InjectMetric('audit_requests_total')
|
||||
private auditCounter: Counter<string>,
|
||||
|
||||
@InjectMetric('audit_duration_seconds')
|
||||
private auditDuration: Histogram<string>,
|
||||
|
||||
@InjectMetric('pending_audits_total')
|
||||
private pendingGauge: Gauge<string>,
|
||||
) {}
|
||||
|
||||
recordAuditRequest(platform: string, result: string) {
|
||||
this.auditCounter.labels(platform, result).inc();
|
||||
}
|
||||
|
||||
recordAuditDuration(platform: string, duration: number) {
|
||||
this.auditDuration.labels(platform).observe(duration);
|
||||
}
|
||||
|
||||
updatePendingAudits(count: number) {
|
||||
this.pendingGauge.set(count);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
### ✨ 核心创新点
|
||||
|
||||
1. **🚀 统一异步架构**:
|
||||
- 彻底解决同步/异步平台差异问题
|
||||
- 提供一致的用户体验
|
||||
- 支持轻松扩展新平台
|
||||
|
||||
2. **🔧 平台差异抹平**:
|
||||
- 微信同步API → 异步模式适配
|
||||
- 抖音原生异步 → 保持不变
|
||||
- 业务层完全无感知
|
||||
|
||||
3. **📊 状态驱动设计**:
|
||||
- 清晰的状态流转
|
||||
- 完整的执行链路追踪
|
||||
- 易于监控和调试
|
||||
|
||||
4. **🛡️ 安全可靠**:
|
||||
- 图片URL验证增强
|
||||
- 完整的错误处理
|
||||
- 审核日志记录
|
||||
|
||||
### 🎉 架构优势
|
||||
|
||||
- **扩展性**:新增平台只需实现适配器
|
||||
- **维护性**:统一的接口和状态管理
|
||||
- **性能**:异步非阻塞处理
|
||||
- **用户体验**:响应迅速,状态透明
|
||||
- **监控**:完整的指标和日志
|
||||
|
||||
### 🔮 未来扩展
|
||||
|
||||
1. **智能重试机制**:审核失败自动重试
|
||||
2. **缓存优化**:相同图片审核结果缓存
|
||||
3. **批量处理**:支持大规模批量审核
|
||||
4. **AI增强**:结合自研AI模型预检测
|
||||
5. **实时通知**:WebSocket推送状态更新
|
||||
|
||||
---
|
||||
|
||||
**🎯 这套统一异步架构完美解决了原始的同步审核阻塞异步模板执行的问题,为多平台内容审核提供了优雅的解决方案。**
|
||||
902
docs/upgrade-to-unified-async-architecture.md
Normal file
902
docs/upgrade-to-unified-async-architecture.md
Normal file
@@ -0,0 +1,902 @@
|
||||
# 图片内容审核系统升级方案
|
||||
## 从混合同步/异步架构升级到统一异步架构
|
||||
|
||||
---
|
||||
|
||||
## 🎯 升级概述
|
||||
|
||||
本升级方案旨在将现有的图片内容审核系统从 **混合同步/异步架构** 升级为 **统一异步架构**,解决同步审核阻塞异步模板执行的核心问题,并实现平台差异抹平。
|
||||
|
||||
### 🚨 核心问题
|
||||
|
||||
**现状**:抖音图片审核失败 `抖音图片审核失败: 抖音审核API调用失败: 无效的图片URL`
|
||||
**根因**:
|
||||
1. 图片URL验证逻辑过于严格(已修复)
|
||||
2. **同步审核与异步模板执行的架构矛盾**(本次升级重点)
|
||||
3. 平台差异导致的不一致用户体验
|
||||
|
||||
---
|
||||
|
||||
## 📊 现状分析
|
||||
|
||||
### ✅ 已实现功能
|
||||
|
||||
1. **基础内容审核架构**:
|
||||
- ✅ `UnifiedContentService` 统一服务
|
||||
- ✅ `ContentAdapterFactory` 适配器工厂
|
||||
- ✅ `DouyinContentAdapter` 抖音适配器
|
||||
- ✅ `WechatContentAdapter` 微信适配器
|
||||
- ✅ `BaseContentAdapter` 基础适配器
|
||||
- ✅ 审核日志记录 `ContentAuditLogEntity`
|
||||
|
||||
2. **模板执行系统**:
|
||||
- ✅ `TemplateController.executeTemplateByCode`
|
||||
- ✅ `TemplateExecutionEntity` 执行记录
|
||||
- ✅ 基础状态管理
|
||||
|
||||
3. **环境配置**:
|
||||
- ✅ 审核回调URL配置
|
||||
- ✅ 平台API配置
|
||||
|
||||
### ❌ 存在问题
|
||||
|
||||
1. **架构问题**:
|
||||
```typescript
|
||||
// 🚨 问题:同步等待异步审核结果
|
||||
const auditResult = await this.unifiedContentService.auditImage(platform, auditData);
|
||||
if (auditResult.conclusion !== AuditConclusion.PASS) {
|
||||
throw new HttpException('图片审核未通过', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
// 继续执行模板...
|
||||
```
|
||||
|
||||
2. **状态枚举缺失**:
|
||||
```typescript
|
||||
// 当前状态枚举
|
||||
enum ExecutionStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
// ❌ 缺失:PENDING_AUDIT, AUDIT_FAILED
|
||||
```
|
||||
|
||||
3. **实体字段缺失**:
|
||||
```typescript
|
||||
// TemplateExecutionEntity 缺失字段
|
||||
// ❌ auditTaskId?: string; // 审核任务ID关联
|
||||
```
|
||||
|
||||
4. **平台差异未抹平**:
|
||||
- 微信同步API直接返回结果
|
||||
- 抖音异步API需要回调处理
|
||||
- 业务层需要区别处理
|
||||
|
||||
---
|
||||
|
||||
## 🚀 升级目标架构
|
||||
|
||||
### 🎯 统一异步执行流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户提交] --> B[提交审核<br/>所有平台返回PROCESSING]
|
||||
B --> C[创建执行记录<br/>PENDING_AUDIT状态]
|
||||
C --> D[立即返回executionId]
|
||||
|
||||
B --> E[微信同步平台<br/>setImmediate触发回调]
|
||||
B --> F[抖音异步平台<br/>外部回调]
|
||||
|
||||
E --> G[审核完成回调]
|
||||
F --> G
|
||||
|
||||
G --> H{审核结果}
|
||||
H -->|PASS| I[更新状态PROCESSING<br/>开始执行模板]
|
||||
H -->|REJECT| J[更新状态AUDIT_FAILED]
|
||||
|
||||
I --> K[执行完成<br/>COMPLETED状态]
|
||||
|
||||
D --> L[用户轮询状态]
|
||||
L --> M[返回最新状态]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 升级计划
|
||||
|
||||
### 阶段一:核心架构升级 (1-2天)
|
||||
|
||||
#### 1.1 扩展执行状态枚举
|
||||
```typescript
|
||||
// 📁 src/entities/template-execution.entity.ts
|
||||
export enum ExecutionStatus {
|
||||
PENDING = 'pending',
|
||||
PENDING_AUDIT = 'pending_audit', // 🆕 待审核
|
||||
AUDIT_FAILED = 'audit_failed', // 🆕 审核失败
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 扩展模板执行实体
|
||||
```typescript
|
||||
// 📁 src/entities/template-execution.entity.ts
|
||||
@Entity('template_executions')
|
||||
export class TemplateExecutionEntity {
|
||||
// 现有字段...
|
||||
|
||||
/** 🆕 审核任务ID - 关联审核任务,用于回调匹配 */
|
||||
@Column({ name: 'audit_task_id', nullable: true })
|
||||
auditTaskId?: string;
|
||||
|
||||
/** 🔄 修改默认状态 */
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ExecutionStatus,
|
||||
default: ExecutionStatus.PENDING_AUDIT, // 🔄 改为待审核
|
||||
})
|
||||
status: ExecutionStatus;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 创建增强基础适配器
|
||||
```typescript
|
||||
// 📁 src/content-moderation/adapters/enhanced-base-content.adapter.ts
|
||||
export abstract class EnhancedBaseContentAdapter extends BaseContentAdapter {
|
||||
abstract readonly isSyncPlatform: boolean;
|
||||
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
// 1. 调用平台API
|
||||
const platformResult = await this.callPlatformAuditAPI(auditData);
|
||||
|
||||
// 2. 🎯 统一返回PROCESSING状态
|
||||
const unifiedResult = {
|
||||
taskId: auditData.taskId,
|
||||
status: AuditStatus.PROCESSING,
|
||||
conclusion: AuditConclusion.UNCERTAIN,
|
||||
};
|
||||
|
||||
// 3. 同步平台立即触发回调
|
||||
if (this.isSyncPlatform) {
|
||||
setImmediate(() => this.simulateCallback(platformResult));
|
||||
}
|
||||
|
||||
return unifiedResult;
|
||||
}
|
||||
|
||||
protected abstract callPlatformAuditAPI(auditData: ImageAuditRequest): Promise<any>;
|
||||
protected abstract formatCallbackData(platformResult: any): any;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 创建增强微信适配器
|
||||
```typescript
|
||||
// 📁 src/content-moderation/adapters/enhanced-wechat-content.adapter.ts
|
||||
export class EnhancedWechatContentAdapter extends EnhancedBaseContentAdapter {
|
||||
readonly isSyncPlatform = true;
|
||||
|
||||
protected async callPlatformAuditAPI(auditData: ImageAuditRequest) {
|
||||
// 调用微信同步API
|
||||
const response = await this.callWechatSyncAPI(auditData);
|
||||
return response;
|
||||
}
|
||||
|
||||
protected formatCallbackData(platformResult: any) {
|
||||
// 格式化为标准回调格式
|
||||
return {
|
||||
task_id: platformResult.taskId,
|
||||
status: 1, // 完成
|
||||
conclusion: platformResult.isPass ? 1 : 2,
|
||||
// ...
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 阶段二:控制器升级 (1天)
|
||||
|
||||
#### 2.1 创建增强模板控制器
|
||||
```typescript
|
||||
// 📁 src/controllers/enhanced-template.controller.ts
|
||||
@Controller('enhanced/templates')
|
||||
export class EnhancedTemplateController {
|
||||
|
||||
@Post('code/:code/execute')
|
||||
async executeTemplateByCode(
|
||||
@Param('code') code: string,
|
||||
@Body() body: { imageUrl: string },
|
||||
@Request() req,
|
||||
) {
|
||||
// 1. 🎯 提交审核(统一返回PROCESSING)
|
||||
const auditResult = await this.unifiedContentService.auditImage(platform, auditData);
|
||||
|
||||
// 2. 🎯 创建执行记录(PENDING_AUDIT状态)
|
||||
const execution = await this.executionRepository.save({
|
||||
templateId: templateConfig.id,
|
||||
userId,
|
||||
auditTaskId: auditResult.taskId, // 🆕 关联审核任务
|
||||
status: ExecutionStatus.PENDING_AUDIT, // 🆕 待审核状态
|
||||
// ...
|
||||
});
|
||||
|
||||
// 3. 🎯 立即返回,不等待审核结果
|
||||
return ResponseUtil.success({
|
||||
executionId: execution.id,
|
||||
auditTaskId: auditResult.taskId,
|
||||
status: 'pending_audit',
|
||||
}, '模板执行已提交,正在进行图片审核');
|
||||
}
|
||||
|
||||
// 🎯 审核完成回调处理
|
||||
async handleAuditComplete(auditTaskId: string, auditResult: ContentAuditResult) {
|
||||
const execution = await this.findByAuditTaskId(auditTaskId);
|
||||
|
||||
if (auditResult.conclusion === AuditConclusion.PASS) {
|
||||
await this.startTemplateExecution(execution);
|
||||
} else {
|
||||
await this.updateStatus(execution.id, ExecutionStatus.AUDIT_FAILED);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 增强状态查询接口
|
||||
```typescript
|
||||
@Get('executions/:executionId/status')
|
||||
async getExecutionStatus(@Param('executionId') executionId: number) {
|
||||
const execution = await this.executionRepository.findOne({
|
||||
where: { id: executionId },
|
||||
relations: ['template'],
|
||||
});
|
||||
|
||||
// 🎯 如果是待审核状态,主动查询最新审核结果
|
||||
if (execution.status === ExecutionStatus.PENDING_AUDIT) {
|
||||
const auditResult = await this.unifiedContentService.queryAuditResult(
|
||||
execution.platform,
|
||||
execution.auditTaskId
|
||||
);
|
||||
|
||||
if (auditResult.status === AuditStatus.COMPLETED) {
|
||||
await this.handleAuditComplete(execution.auditTaskId, auditResult);
|
||||
// 重新查询更新后的状态
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseUtil.success({
|
||||
executionId: execution.id,
|
||||
status: execution.status,
|
||||
statusDescription: this.getStatusDescription(execution.status),
|
||||
// ...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 阶段三:回调集成升级 (1天)
|
||||
|
||||
#### 3.1 增强回调处理
|
||||
```typescript
|
||||
// 📁 src/content-moderation/adapters/enhanced-wechat-content.adapter.ts
|
||||
async handleAuditCallback(callbackData: any): Promise<void> {
|
||||
try {
|
||||
// 1. 解析回调数据
|
||||
const auditResult = this.parseCallbackData(callbackData);
|
||||
|
||||
// 2. 更新审核日志
|
||||
await this.updateAuditLog(auditResult.taskId, auditResult);
|
||||
|
||||
// 3. 🎯 通知模板执行系统
|
||||
await this.notifyTemplateExecution(auditResult.taskId, auditResult);
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理审核回调失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyTemplateExecution(taskId: string, auditResult: ContentAuditResult) {
|
||||
// 🎯 集成点:调用模板执行控制器的回调处理
|
||||
const templateController = Container.get(EnhancedTemplateController);
|
||||
await templateController.handleAuditComplete(taskId, auditResult);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 适配器工厂升级
|
||||
```typescript
|
||||
// 📁 src/content-moderation/services/content-adapter.factory.ts
|
||||
@Injectable()
|
||||
export class ContentAdapterFactory {
|
||||
|
||||
getAdapter(platform: PlatformType): IContentModerationAdapter {
|
||||
switch (platform) {
|
||||
case PlatformType.BYTEDANCE:
|
||||
return this.douyinAdapter; // 原生异步
|
||||
case PlatformType.WECHAT:
|
||||
return this.enhancedWechatAdapter; // 🆕 使用增强版
|
||||
default:
|
||||
throw new BadRequestException(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 阶段四:数据库迁移 (0.5天)
|
||||
|
||||
#### 4.1 MySQL数据库迁移脚本
|
||||
|
||||
**📁 创建 TypeORM 迁移文件:**
|
||||
```typescript
|
||||
// migrations/1726000000000-UpgradeToUnifiedAsyncArchitecture.ts
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 升级到统一异步架构
|
||||
* 1. 添加审核任务ID字段
|
||||
* 2. 扩展状态枚举支持审核状态
|
||||
* 3. 修改默认状态为待审核
|
||||
* 4. 添加相关索引
|
||||
*/
|
||||
export class UpgradeToUnifiedAsyncArchitecture1726000000000 implements MigrationInterface {
|
||||
name = 'UpgradeToUnifiedAsyncArchitecture1726000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. 添加审核任务ID字段
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`template_executions\`
|
||||
ADD COLUMN \`audit_task_id\` VARCHAR(255) NULL
|
||||
COMMENT '审核任务ID,用于关联审核记录'
|
||||
`);
|
||||
|
||||
// 2. 扩展状态枚举 - MySQL需要重新定义整个ENUM
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`template_executions\`
|
||||
MODIFY COLUMN \`status\` ENUM(
|
||||
'pending',
|
||||
'pending_audit',
|
||||
'audit_failed',
|
||||
'processing',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
) NOT NULL DEFAULT 'pending_audit'
|
||||
`);
|
||||
|
||||
// 3. 更新现有记录的状态:将pending改为pending_audit
|
||||
await queryRunner.query(`
|
||||
UPDATE \`template_executions\`
|
||||
SET \`status\` = 'pending_audit'
|
||||
WHERE \`status\` = 'pending'
|
||||
`);
|
||||
|
||||
// 4. 添加索引 - 使用反引号包围字段名
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX \`IDX_template_executions_audit_task_id\`
|
||||
ON \`template_executions\`(\`audit_task_id\`)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX \`IDX_template_executions_status_updated\`
|
||||
ON \`template_executions\`(\`status\`, \`updatedAt\`)
|
||||
`);
|
||||
|
||||
// 5. 添加复合索引以优化查询性能
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX \`IDX_template_executions_user_status\`
|
||||
ON \`template_executions\`(\`userId\`, \`status\`, \`createdAt\`)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 回滚步骤:删除索引和字段,恢复原始状态枚举
|
||||
|
||||
// 1. 删除新增的索引
|
||||
await queryRunner.query(`DROP INDEX \`IDX_template_executions_audit_task_id\` ON \`template_executions\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_template_executions_status_updated\` ON \`template_executions\``);
|
||||
await queryRunner.query(`DROP INDEX \`IDX_template_executions_user_status\` ON \`template_executions\``);
|
||||
|
||||
// 2. 将pending_audit状态改回pending(数据迁移)
|
||||
await queryRunner.query(`
|
||||
UPDATE \`template_executions\`
|
||||
SET \`status\` = 'pending'
|
||||
WHERE \`status\` IN ('pending_audit', 'audit_failed')
|
||||
`);
|
||||
|
||||
// 3. 恢复原始状态枚举
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`template_executions\`
|
||||
MODIFY COLUMN \`status\` ENUM(
|
||||
'pending',
|
||||
'processing',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
) NOT NULL DEFAULT 'pending'
|
||||
`);
|
||||
|
||||
// 4. 删除审核任务ID字段
|
||||
await queryRunner.query(`ALTER TABLE \`template_executions\` DROP COLUMN \`audit_task_id\``);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**📁 备用手动SQL脚本(如需要):**
|
||||
```sql
|
||||
-- migrations/manual-upgrade-unified-async.sql
|
||||
-- 手动执行时使用(建议使用TypeORM迁移)
|
||||
|
||||
-- 备份现有数据
|
||||
CREATE TABLE template_executions_backup_20241205 AS
|
||||
SELECT * FROM template_executions;
|
||||
|
||||
-- 添加审核任务ID字段
|
||||
ALTER TABLE `template_executions`
|
||||
ADD COLUMN `audit_task_id` VARCHAR(255) NULL
|
||||
COMMENT '审核任务ID,用于关联审核记录';
|
||||
|
||||
-- 扩展状态枚举
|
||||
ALTER TABLE `template_executions`
|
||||
MODIFY COLUMN `status` ENUM(
|
||||
'pending',
|
||||
'pending_audit',
|
||||
'audit_failed',
|
||||
'processing',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
) NOT NULL DEFAULT 'pending_audit';
|
||||
|
||||
-- 更新现有数据
|
||||
UPDATE `template_executions`
|
||||
SET `status` = 'pending_audit'
|
||||
WHERE `status` = 'pending';
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX `IDX_template_executions_audit_task_id`
|
||||
ON `template_executions`(`audit_task_id`);
|
||||
|
||||
CREATE INDEX `IDX_template_executions_status_updated`
|
||||
ON `template_executions`(`status`, `updatedAt`);
|
||||
|
||||
CREATE INDEX `IDX_template_executions_user_status`
|
||||
ON `template_executions`(`userId`, `status`, `createdAt`);
|
||||
|
||||
-- 验证迁移结果
|
||||
SELECT
|
||||
COUNT(*) as total_records,
|
||||
COUNT(CASE WHEN status = 'pending_audit' THEN 1 END) as pending_audit_count,
|
||||
COUNT(CASE WHEN audit_task_id IS NOT NULL THEN 1 END) as with_audit_task_id
|
||||
FROM template_executions;
|
||||
```
|
||||
|
||||
#### 4.2 数据迁移脚本
|
||||
```typescript
|
||||
// 📁 scripts/migrate-existing-executions.ts
|
||||
import { getRepository } from 'typeorm';
|
||||
import { TemplateExecutionEntity, ExecutionStatus } from '../src/entities/template-execution.entity';
|
||||
|
||||
async function migrateExistingExecutions() {
|
||||
const repository = getRepository(TemplateExecutionEntity);
|
||||
|
||||
// 将现有的 PENDING 状态改为 PENDING_AUDIT
|
||||
await repository.update(
|
||||
{ status: ExecutionStatus.PENDING as any },
|
||||
{ status: ExecutionStatus.PENDING_AUDIT }
|
||||
);
|
||||
|
||||
console.log('迁移完成:现有执行记录状态已更新');
|
||||
}
|
||||
```
|
||||
|
||||
### 阶段五:测试和部署 (1天)
|
||||
|
||||
#### 5.1 单元测试升级
|
||||
```typescript
|
||||
// 📁 src/controllers/__tests__/enhanced-template.controller.spec.ts
|
||||
describe('EnhancedTemplateController', () => {
|
||||
it('should return pending_audit status immediately', async () => {
|
||||
const response = await controller.executeTemplateByCode('photo_restore_v1', {
|
||||
imageUrl: 'https://example.com/test.jpg'
|
||||
});
|
||||
|
||||
expect(response.data.status).toBe('pending_audit');
|
||||
expect(response.data.executionId).toBeDefined();
|
||||
expect(response.data.auditTaskId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle audit completion callback', async () => {
|
||||
const auditResult = {
|
||||
taskId: 'audit_task_123',
|
||||
conclusion: AuditConclusion.PASS,
|
||||
};
|
||||
|
||||
await controller.handleAuditComplete('audit_task_123', auditResult);
|
||||
|
||||
// 验证执行记录状态更新为 PROCESSING
|
||||
const execution = await repository.findByAuditTaskId('audit_task_123');
|
||||
expect(execution.status).toBe(ExecutionStatus.PROCESSING);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 5.2 集成测试
|
||||
```typescript
|
||||
// 📁 test/integration/unified-async-flow.e2e-spec.ts
|
||||
describe('Unified Async Flow (E2E)', () => {
|
||||
it('should complete full async execution flow', async () => {
|
||||
// 1. 提交模板执行
|
||||
const submitResponse = await request(app.getHttpServer())
|
||||
.post('/enhanced/templates/code/photo_restore_v1/execute')
|
||||
.send({ imageUrl: 'https://example.com/test.jpg' })
|
||||
.expect(200);
|
||||
|
||||
const { executionId, auditTaskId } = submitResponse.body.data;
|
||||
|
||||
// 2. 立即查询状态 - 应该是待审核
|
||||
const statusResponse1 = await request(app.getHttpServer())
|
||||
.get(`/enhanced/templates/executions/${executionId}/status`)
|
||||
.expect(200);
|
||||
|
||||
expect(statusResponse1.body.data.status).toBe('pending_audit');
|
||||
|
||||
// 3. 模拟审核回调
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/content-moderation/wechat/callback')
|
||||
.send({
|
||||
task_id: auditTaskId,
|
||||
status: 1,
|
||||
conclusion: 1,
|
||||
confidence: 95,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// 4. 再次查询状态 - 应该是处理中或已完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待异步处理
|
||||
|
||||
const statusResponse2 = await request(app.getHttpServer())
|
||||
.get(`/enhanced/templates/executions/${executionId}/status`)
|
||||
.expect(200);
|
||||
|
||||
expect(['processing', 'completed']).toContain(statusResponse2.body.data.status);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 升级清单
|
||||
|
||||
### 🔧 代码变更清单
|
||||
|
||||
#### 新增文件
|
||||
- [ ] `src/content-moderation/adapters/enhanced-base-content.adapter.ts`
|
||||
- [ ] `src/content-moderation/adapters/enhanced-wechat-content.adapter.ts`
|
||||
- [ ] `src/controllers/enhanced-template.controller.ts`
|
||||
- [ ] `migrations/xxx-add-audit-task-id-and-status.ts`
|
||||
- [ ] `scripts/migrate-existing-executions.ts`
|
||||
|
||||
#### 修改文件
|
||||
- [ ] `src/entities/template-execution.entity.ts` - 扩展状态枚举和字段
|
||||
- [ ] `src/content-moderation/services/content-adapter.factory.ts` - 适配器选择逻辑
|
||||
- [ ] `src/content-moderation/adapters/douyin-content.adapter.ts` - 回调通知集成
|
||||
- [ ] `src/content-moderation/adapters/wechat-content.adapter.ts` - 回调通知集成
|
||||
|
||||
#### 测试文件
|
||||
- [ ] `src/controllers/__tests__/enhanced-template.controller.spec.ts`
|
||||
- [ ] `test/integration/unified-async-flow.e2e-spec.ts`
|
||||
- [ ] 更新现有测试用例
|
||||
|
||||
### 🗄️ 数据库变更清单
|
||||
|
||||
- [ ] 添加 `audit_task_id` 字段
|
||||
- [ ] 扩展 `status` 枚举值
|
||||
- [ ] 修改默认状态值
|
||||
- [ ] 添加相关索引
|
||||
- [ ] 执行数据迁移脚本
|
||||
|
||||
### 🌐 环境配置清单
|
||||
|
||||
- [ ] 验证 `AUDIT_CALLBACK_URL` 配置
|
||||
- [ ] 验证 `BYTEDANCE_AUDIT_API_URL` 配置
|
||||
- [ ] 验证 `WECHAT_AUDIT_API_URL` 配置
|
||||
- [ ] 确保回调URL的可访问性
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署方案
|
||||
|
||||
### MySQL 数据库升级步骤
|
||||
|
||||
1. **Phase 1 - 数据库备份和升级**:
|
||||
```bash
|
||||
# 1. 创建完整数据库备份
|
||||
mysqldump -u root -p \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--single-transaction \
|
||||
--lock-tables=false \
|
||||
nano_camera_miniapp > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# 2. 验证备份文件
|
||||
ls -lh backup_*.sql
|
||||
|
||||
# 3. 创建TypeORM迁移文件
|
||||
npm run migration:generate -- -n UpgradeToUnifiedAsyncArchitecture
|
||||
|
||||
# 4. 执行迁移(建议在低峰时段)
|
||||
npm run migration:run
|
||||
|
||||
# 5. 验证迁移结果
|
||||
mysql -u root -p -e "
|
||||
USE nano_camera_miniapp;
|
||||
DESCRIBE template_executions;
|
||||
SELECT status, COUNT(*) FROM template_executions GROUP BY status;
|
||||
SHOW INDEX FROM template_executions;
|
||||
"
|
||||
```
|
||||
|
||||
2. **Phase 1.5 - MySQL性能优化**:
|
||||
```sql
|
||||
-- 检查表大小和索引效率
|
||||
SELECT
|
||||
table_name,
|
||||
table_rows,
|
||||
ROUND(((data_length + index_length) / 1024 / 1024), 2) AS 'Size (MB)'
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema = 'nano_camera_miniapp'
|
||||
AND table_name = 'template_executions';
|
||||
|
||||
-- 检查索引使用情况(运行一段时间后执行)
|
||||
SHOW INDEX FROM template_executions;
|
||||
|
||||
-- 分析查询性能
|
||||
EXPLAIN SELECT * FROM template_executions
|
||||
WHERE userId = 'test_user' AND status = 'pending_audit'
|
||||
ORDER BY createdAt DESC LIMIT 10;
|
||||
```
|
||||
|
||||
2. **Phase 2 - 代码部署**:
|
||||
```bash
|
||||
# 1. 部署新代码
|
||||
git checkout main
|
||||
git pull origin main
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# 2. 重启应用
|
||||
pm2 restart app
|
||||
```
|
||||
|
||||
3. **Phase 3 - 功能验证**:
|
||||
```bash
|
||||
# 1. 运行集成测试
|
||||
npm run test:e2e
|
||||
|
||||
# 2. 手动测试关键流程
|
||||
curl -X POST /enhanced/templates/code/photo_restore_v1/execute
|
||||
```
|
||||
|
||||
### 灰度方案
|
||||
|
||||
```typescript
|
||||
// 环境变量控制灰度
|
||||
const USE_ENHANCED_CONTROLLER = process.env.USE_ENHANCED_CONTROLLER === 'true';
|
||||
|
||||
@Controller(USE_ENHANCED_CONTROLLER ? 'enhanced/templates' : 'templates')
|
||||
export class TemplateController {
|
||||
// 原有逻辑保持不变,确保向后兼容
|
||||
}
|
||||
```
|
||||
|
||||
### 回滚方案
|
||||
|
||||
1. **代码回滚**:
|
||||
```bash
|
||||
git checkout [previous_commit_hash]
|
||||
npm run build
|
||||
pm2 restart app
|
||||
```
|
||||
|
||||
2. **MySQL数据库回滚**:
|
||||
```bash
|
||||
# 1. 如果使用TypeORM迁移,直接回滚
|
||||
npm run migration:revert
|
||||
|
||||
# 2. 如果需要手动回滚
|
||||
mysql -u root -p nano_camera_miniapp << 'EOF'
|
||||
-- 删除新增索引
|
||||
DROP INDEX `IDX_template_executions_audit_task_id` ON `template_executions`;
|
||||
DROP INDEX `IDX_template_executions_status_updated` ON `template_executions`;
|
||||
DROP INDEX `IDX_template_executions_user_status` ON `template_executions`;
|
||||
|
||||
-- 更新状态数据
|
||||
UPDATE `template_executions`
|
||||
SET `status` = 'pending'
|
||||
WHERE `status` IN ('pending_audit', 'audit_failed');
|
||||
|
||||
-- 恢复原始ENUM
|
||||
ALTER TABLE `template_executions`
|
||||
MODIFY COLUMN `status` ENUM('pending','processing','completed','failed','cancelled')
|
||||
NOT NULL DEFAULT 'pending';
|
||||
|
||||
-- 删除字段
|
||||
ALTER TABLE `template_executions` DROP COLUMN `audit_task_id`;
|
||||
EOF
|
||||
|
||||
# 3. 验证回滚结果
|
||||
mysql -u root -p -e "
|
||||
USE nano_camera_miniapp;
|
||||
DESCRIBE template_executions;
|
||||
SELECT status, COUNT(*) FROM template_executions GROUP BY status;
|
||||
"
|
||||
```
|
||||
|
||||
### 🔧 MySQL特有注意事项
|
||||
|
||||
#### 版本兼容性
|
||||
```bash
|
||||
# 检查MySQL版本
|
||||
mysql --version
|
||||
|
||||
# 确保版本兼容性:
|
||||
# ✅ MySQL 5.7+ : 支持JSON字段类型
|
||||
# ✅ MySQL 8.0+ : 更好的索引性能,推荐
|
||||
# ❌ MySQL 5.6及以下 : 不支持JSON字段,需要升级
|
||||
|
||||
# 检查当前实例特性
|
||||
mysql -u root -p -e "
|
||||
SELECT VERSION() as mysql_version;
|
||||
SHOW VARIABLES LIKE 'innodb_version';
|
||||
SHOW VARIABLES LIKE 'default_storage_engine';
|
||||
"
|
||||
```
|
||||
|
||||
#### ENUM类型处理
|
||||
```sql
|
||||
-- ⚠️ MySQL ENUM修改注意事项:
|
||||
-- 1. MySQL不能直接添加ENUM值到中间位置,需要重新定义整个ENUM
|
||||
-- 2. 修改ENUM会锁表,建议在低峰时段操作
|
||||
-- 3. 大表修改ENUM可能耗时较长
|
||||
|
||||
-- 检查当前ENUM定义
|
||||
SELECT COLUMN_TYPE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'nano_camera_miniapp'
|
||||
AND TABLE_NAME = 'template_executions'
|
||||
AND COLUMN_NAME = 'status';
|
||||
```
|
||||
|
||||
#### 索引优化
|
||||
```sql
|
||||
-- MySQL索引命名约定(项目已遵循)
|
||||
-- 前缀: IDX_表名_字段名
|
||||
-- 示例: IDX_template_executions_audit_task_id
|
||||
|
||||
-- 检查索引碎片化
|
||||
SELECT
|
||||
table_name,
|
||||
index_name,
|
||||
cardinality,
|
||||
pages,
|
||||
avg_page_frag
|
||||
FROM mysql.innodb_index_stats
|
||||
WHERE database_name = 'nano_camera_miniapp'
|
||||
AND table_name = 'template_executions';
|
||||
|
||||
-- 如果碎片化严重,重建索引
|
||||
-- ALTER TABLE template_executions ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
#### 性能监控
|
||||
```sql
|
||||
-- 监控慢查询(my.cnf配置)
|
||||
-- slow_query_log = 1
|
||||
-- slow_query_log_file = /var/log/mysql-slow.log
|
||||
-- long_query_time = 2
|
||||
|
||||
-- 查看执行中的查询
|
||||
SHOW PROCESSLIST;
|
||||
|
||||
-- 查看表锁定情况
|
||||
SHOW OPEN TABLES WHERE In_use > 0;
|
||||
```
|
||||
|
||||
#### MySQL配置优化建议
|
||||
```ini
|
||||
# my.cnf 或 my.ini 配置优化
|
||||
[mysqld]
|
||||
# 基础性能优化
|
||||
innodb_buffer_pool_size = 1G # 根据可用内存调整
|
||||
innodb_log_file_size = 256M # 事务日志大小
|
||||
innodb_flush_log_at_trx_commit = 1 # 事务安全性
|
||||
innodb_file_per_table = 1 # 每个表独立表空间
|
||||
|
||||
# 连接和查询优化
|
||||
max_connections = 500 # 最大连接数
|
||||
wait_timeout = 300 # 连接超时
|
||||
interactive_timeout = 300 # 交互超时
|
||||
|
||||
# 索引和排序优化
|
||||
sort_buffer_size = 2M # 排序缓冲区
|
||||
read_buffer_size = 1M # 读缓冲区
|
||||
tmp_table_size = 64M # 临时表大小
|
||||
max_heap_table_size = 64M # 内存表大小
|
||||
|
||||
# 慢查询日志(监控性能)
|
||||
slow_query_log = 1
|
||||
slow_query_log_file = /var/log/mysql/slow.log
|
||||
long_query_time = 2
|
||||
|
||||
# 二进制日志(数据安全)
|
||||
log_bin = mysql-bin
|
||||
binlog_format = ROW
|
||||
expire_logs_days = 7
|
||||
```
|
||||
|
||||
#### 大表迁移策略
|
||||
```bash
|
||||
# 如果 template_executions 表很大(>100万记录),使用在线迁移工具
|
||||
# 1. 安装 pt-online-schema-change(Percona Toolkit)
|
||||
# 2. 使用在线迁移避免锁表
|
||||
|
||||
# 示例:在线添加字段(如果表很大)
|
||||
pt-online-schema-change \
|
||||
--alter "ADD COLUMN audit_task_id VARCHAR(255) NULL" \
|
||||
--host=localhost \
|
||||
--user=root \
|
||||
--ask-pass \
|
||||
--execute \
|
||||
D=nano_camera_miniapp,t=template_executions
|
||||
|
||||
# 3. 在线修改ENUM(大表推荐)
|
||||
pt-online-schema-change \
|
||||
--alter "MODIFY COLUMN status ENUM('pending','pending_audit','audit_failed','processing','completed','failed','cancelled') NOT NULL DEFAULT 'pending_audit'" \
|
||||
--host=localhost \
|
||||
--user=root \
|
||||
--ask-pass \
|
||||
--execute \
|
||||
D=nano_camera_miniapp,t=template_executions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 预期效果
|
||||
|
||||
### 🎯 问题解决
|
||||
|
||||
1. **同步审核阻塞问题** → ✅ 统一异步,立即响应
|
||||
2. **平台差异体验** → ✅ 一致的异步体验
|
||||
3. **图片URL验证失败** → ✅ 已优化验证逻辑
|
||||
4. **状态追踪不清晰** → ✅ 完整的状态流转
|
||||
|
||||
### 📊 性能提升
|
||||
|
||||
- **响应时间**:从审核时间(2-5秒) → 200ms内
|
||||
- **用户体验**:阻塞等待 → 异步轮询状态
|
||||
- **系统吞吐**:串行执行 → 并行处理
|
||||
- **错误恢复**:审核失败阻断 → 独立状态管理
|
||||
|
||||
### 🔮 扩展能力
|
||||
|
||||
- **新平台接入**:只需实现适配器,业务层无感知
|
||||
- **批量处理**:天然支持大规模并发审核
|
||||
- **监控告警**:完整的状态和指标监控
|
||||
- **智能重试**:基于状态的重试机制
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
本升级方案通过 **统一异步架构** 和 **平台差异抹平技术**,彻底解决了同步审核与异步模板执行的架构矛盾。升级后系统将具备:
|
||||
|
||||
- 🚀 **一致的异步体验**:所有平台统一异步模式
|
||||
- 🔧 **优雅的架构设计**:清晰的状态流转和回调机制
|
||||
- 📈 **更好的性能**:非阻塞处理,提升吞吐量
|
||||
- 🛡️ **更强的扩展性**:新平台接入成本低
|
||||
- 🎯 **完善的监控**:全链路状态跟踪
|
||||
|
||||
**预计升级周期:3-4个工作日**
|
||||
**预计收益:彻底解决现有架构问题,为未来扩展奠定坚实基础**
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TemplateExecutionEntity, ExecutionStatus } from './entities/template-execution.entity';
|
||||
import {
|
||||
TemplateExecutionEntity,
|
||||
ExecutionStatus,
|
||||
} from './entities/template-execution.entity';
|
||||
import { ResponseUtil, ApiResponse } from './utils/response.util';
|
||||
|
||||
@Controller()
|
||||
@@ -9,16 +12,16 @@ export class AppController {
|
||||
constructor(
|
||||
@InjectRepository(TemplateExecutionEntity)
|
||||
private readonly executionRepository: Repository<TemplateExecutionEntity>,
|
||||
) { }
|
||||
) {}
|
||||
@Post('callback')
|
||||
async callback(@Body() body: any): Promise<ApiResponse<string | null>> {
|
||||
console.log(`🚀 [回调] 开始执行回调`);
|
||||
console.log(`📋 回调参数: ${JSON.stringify(body, null, 2)}`);
|
||||
const task_id = body.task_id;
|
||||
if (!task_id) return ResponseUtil.error('缺少task_id', 400)
|
||||
if (!task_id) return ResponseUtil.error('缺少task_id', 400);
|
||||
const res = body.data;
|
||||
// {status: true, data: string[], task_id: string}
|
||||
let resultUrl = ``
|
||||
let resultUrl = ``;
|
||||
if (body.status) {
|
||||
const data = body.data;
|
||||
if (!data) throw new Error(`结果有误`);
|
||||
@@ -29,7 +32,7 @@ export class AppController {
|
||||
}
|
||||
// 通过 taskId 查找对应的执行记录并更新状态
|
||||
const execution = await this.executionRepository.findOne({
|
||||
where: { taskId: task_id }
|
||||
where: { taskId: task_id },
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
|
||||
@@ -56,4 +56,4 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
providers: [TemplateService, TemplateManager, N8nTemplateFactoryService],
|
||||
exports: [N8nTemplateFactoryService],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import {
|
||||
ImageAuditRequest,
|
||||
AuditStatus,
|
||||
AuditConclusion
|
||||
AuditConclusion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
describe('DouyinContentAdapter', () => {
|
||||
@@ -65,15 +65,17 @@ describe('DouyinContentAdapter', () => {
|
||||
);
|
||||
|
||||
// Setup default config mock returns
|
||||
mockConfigService.get.mockImplementation((key: string, defaultValue?: string) => {
|
||||
mockConfigService.get.mockImplementation(
|
||||
(key: string, defaultValue?: string) => {
|
||||
const configs = {
|
||||
'BYTEDANCE_APP_ID': 'test_app_id',
|
||||
'BYTEDANCE_APP_SECRET': 'test_app_secret',
|
||||
'BYTEDANCE_AUDIT_API_URL': 'https://test.api.com/audit',
|
||||
'AUDIT_CALLBACK_URL': 'https://test.callback.com',
|
||||
BYTEDANCE_APP_ID: 'test_app_id',
|
||||
BYTEDANCE_APP_SECRET: 'test_app_secret',
|
||||
BYTEDANCE_AUDIT_API_URL: 'https://test.api.com/audit',
|
||||
AUDIT_CALLBACK_URL: 'https://test.callback.com',
|
||||
};
|
||||
return configs[key] || defaultValue;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -139,7 +141,10 @@ describe('DouyinContentAdapter', () => {
|
||||
|
||||
expect(mockHttpService.axiosRef.head).toHaveBeenCalledWith(
|
||||
auditData.imageUrl,
|
||||
{ timeout: 5000 }
|
||||
{
|
||||
timeout: 5000,
|
||||
validateStatus: expect.any(Function),
|
||||
},
|
||||
);
|
||||
expect(mockRepository.save).toHaveBeenCalled();
|
||||
expect(mockHttpService.axiosRef.post).toHaveBeenCalledWith(
|
||||
@@ -150,14 +155,14 @@ describe('DouyinContentAdapter', () => {
|
||||
task_id: mockAuditLog.taskId,
|
||||
callback_url: 'https://test.callback.com',
|
||||
}),
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockRepository.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle invalid image URL', async () => {
|
||||
const auditData: ImageAuditRequest = {
|
||||
imageUrl: 'https://example.com/invalid.jpg',
|
||||
imageUrl: 'invalid-url', // 无效的URL格式
|
||||
userId: 'user123',
|
||||
};
|
||||
|
||||
@@ -166,9 +171,10 @@ describe('DouyinContentAdapter', () => {
|
||||
};
|
||||
|
||||
mockRepository.save.mockResolvedValue(mockAuditLog);
|
||||
mockHttpService.axiosRef.head.mockRejectedValue(new Error('Invalid URL'));
|
||||
|
||||
await expect(adapter.auditImage(auditData)).rejects.toThrow('无效的图片URL');
|
||||
await expect(adapter.auditImage(auditData)).rejects.toThrow(
|
||||
'无效的图片URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Douyin API error', async () => {
|
||||
@@ -218,7 +224,7 @@ describe('DouyinContentAdapter', () => {
|
||||
|
||||
expect(result).toEqual(mockAuditLog.auditResult);
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { taskId, platform: PlatformType.BYTEDANCE }
|
||||
where: { taskId, platform: PlatformType.BYTEDANCE },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,7 +262,7 @@ describe('DouyinContentAdapter', () => {
|
||||
app_id: 'test_app_id',
|
||||
task_id: taskId,
|
||||
}),
|
||||
expect.any(Object)
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -265,7 +271,9 @@ describe('DouyinContentAdapter', () => {
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(adapter.queryAuditResult(taskId)).rejects.toThrow('审核任务不存在');
|
||||
await expect(adapter.queryAuditResult(taskId)).rejects.toThrow(
|
||||
'审核任务不存在',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,7 +285,8 @@ describe('DouyinContentAdapter', () => {
|
||||
];
|
||||
|
||||
// Mock successful first image, failed second image
|
||||
jest.spyOn(adapter, 'auditImage')
|
||||
jest
|
||||
.spyOn(adapter, 'auditImage')
|
||||
.mockResolvedValueOnce({
|
||||
taskId: 'task1',
|
||||
status: AuditStatus.COMPLETED,
|
||||
@@ -325,7 +334,7 @@ describe('DouyinContentAdapter', () => {
|
||||
status: AuditStatus.COMPLETED,
|
||||
conclusion: AuditConclusion.REJECT,
|
||||
confidence: 85,
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
RiskLevel,
|
||||
AuditSuggestion
|
||||
AuditSuggestion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
describe('UnifiedContentService', () => {
|
||||
@@ -46,7 +46,9 @@ describe('UnifiedContentService', () => {
|
||||
provide: ContentAdapterFactory,
|
||||
useValue: {
|
||||
getAdapter: jest.fn().mockReturnValue(mockAdapter),
|
||||
getSupportedPlatforms: jest.fn().mockReturnValue([PlatformType.BYTEDANCE, PlatformType.WECHAT]),
|
||||
getSupportedPlatforms: jest
|
||||
.fn()
|
||||
.mockReturnValue([PlatformType.BYTEDANCE, PlatformType.WECHAT]),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -57,7 +59,9 @@ describe('UnifiedContentService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<UnifiedContentService>(UnifiedContentService);
|
||||
contentAdapterFactory = module.get<ContentAdapterFactory>(ContentAdapterFactory);
|
||||
contentAdapterFactory = module.get<ContentAdapterFactory>(
|
||||
ContentAdapterFactory,
|
||||
);
|
||||
auditLogRepository = module.get<Repository<ContentAuditLogEntity>>(
|
||||
getRepositoryToken(ContentAuditLogEntity),
|
||||
);
|
||||
@@ -91,9 +95,14 @@ describe('UnifiedContentService', () => {
|
||||
|
||||
mockAdapter.auditImage.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await service.auditImage(PlatformType.BYTEDANCE, auditData);
|
||||
const result = await service.auditImage(
|
||||
PlatformType.BYTEDANCE,
|
||||
auditData,
|
||||
);
|
||||
|
||||
expect(contentAdapterFactory.getAdapter).toHaveBeenCalledWith(PlatformType.BYTEDANCE);
|
||||
expect(contentAdapterFactory.getAdapter).toHaveBeenCalledWith(
|
||||
PlatformType.BYTEDANCE,
|
||||
);
|
||||
expect(mockAdapter.auditImage).toHaveBeenCalledWith(auditData);
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
@@ -106,8 +115,9 @@ describe('UnifiedContentService', () => {
|
||||
|
||||
mockAdapter.auditImage.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await expect(service.auditImage(PlatformType.BYTEDANCE, auditData))
|
||||
.rejects.toThrow('API Error');
|
||||
await expect(
|
||||
service.auditImage(PlatformType.BYTEDANCE, auditData),
|
||||
).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +153,10 @@ describe('UnifiedContentService', () => {
|
||||
|
||||
mockAdapter.auditImageBatch.mockResolvedValue(expectedResults);
|
||||
|
||||
const results = await service.auditImageBatch(PlatformType.BYTEDANCE, auditDataList);
|
||||
const results = await service.auditImageBatch(
|
||||
PlatformType.BYTEDANCE,
|
||||
auditDataList,
|
||||
);
|
||||
|
||||
expect(mockAdapter.auditImageBatch).toHaveBeenCalledWith(auditDataList);
|
||||
expect(results).toEqual(expectedResults);
|
||||
@@ -191,7 +204,7 @@ describe('UnifiedContentService', () => {
|
||||
const result = await service.isContentApproved(taskId);
|
||||
|
||||
expect(mockRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { taskId }
|
||||
where: { taskId },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -215,8 +228,9 @@ describe('UnifiedContentService', () => {
|
||||
|
||||
mockRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.isContentApproved(taskId))
|
||||
.rejects.toThrow(BadRequestException);
|
||||
await expect(service.isContentApproved(taskId)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,7 +274,10 @@ describe('UnifiedContentService', () => {
|
||||
const result = await service.getAuditStats(PlatformType.BYTEDANCE);
|
||||
|
||||
expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('audit');
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('audit.platform = :platform', { platform: PlatformType.BYTEDANCE });
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
|
||||
'audit.platform = :platform',
|
||||
{ platform: PlatformType.BYTEDANCE },
|
||||
);
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ImageAuditRequest,
|
||||
ContentAuditResult,
|
||||
AuditStatus,
|
||||
AuditConclusion
|
||||
AuditConclusion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
@Injectable()
|
||||
@@ -27,7 +27,9 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
/**
|
||||
* 创建审核日志记录
|
||||
*/
|
||||
async createAuditLog(auditData: ImageAuditRequest): Promise<ContentAuditLogEntity> {
|
||||
async createAuditLog(
|
||||
auditData: ImageAuditRequest,
|
||||
): Promise<ContentAuditLogEntity> {
|
||||
const auditLog = new ContentAuditLogEntity();
|
||||
auditLog.taskId = auditData.taskId || this.generateTaskId();
|
||||
auditLog.userId = auditData.userId;
|
||||
@@ -44,7 +46,10 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
/**
|
||||
* 更新审核日志结果
|
||||
*/
|
||||
async updateAuditLog(taskId: string, result: ContentAuditResult): Promise<void> {
|
||||
async updateAuditLog(
|
||||
taskId: string,
|
||||
result: ContentAuditResult,
|
||||
): Promise<void> {
|
||||
await this.auditLogRepository.update(
|
||||
{ taskId },
|
||||
{
|
||||
@@ -55,7 +60,7 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
suggestion: result.suggestion,
|
||||
auditResult: result as any,
|
||||
completedAt: new Date(),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,11 +79,15 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
const errorData = error.response.data;
|
||||
// 抖音格式: {err_no, err_tips}
|
||||
if (errorData.err_no !== undefined) {
|
||||
return new Error(`${platform}审核错误[${errorData.err_no}]: ${errorData.err_tips}`);
|
||||
return new Error(
|
||||
`${platform}审核错误[${errorData.err_no}]: ${errorData.err_tips}`,
|
||||
);
|
||||
}
|
||||
// 微信格式: {errcode, errmsg}
|
||||
if (errorData.errcode) {
|
||||
return new Error(`${platform}审核错误[${errorData.errcode}]: ${errorData.errmsg}`);
|
||||
return new Error(
|
||||
`${platform}审核错误[${errorData.errcode}]: ${errorData.errmsg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Error(`${platform}审核API调用失败: ${error.message}`);
|
||||
@@ -89,10 +98,36 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
*/
|
||||
protected async validateImageUrl(imageUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.head(imageUrl, { timeout: 5000 });
|
||||
// 1. 检查URL格式和文件扩展名
|
||||
const url = new URL(imageUrl);
|
||||
const pathname = url.pathname.toLowerCase();
|
||||
const imageExtensions = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.webp',
|
||||
];
|
||||
const hasImageExtension = imageExtensions.some((ext) =>
|
||||
pathname.endsWith(ext),
|
||||
);
|
||||
|
||||
// 2. 发送HEAD请求检查资源可访问性
|
||||
const response = await this.httpService.axiosRef.head(imageUrl, {
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status === 200,
|
||||
});
|
||||
|
||||
// 3. 检查Content-Type(如果存在)
|
||||
const contentType = response.headers['content-type'];
|
||||
return contentType && contentType.startsWith('image/');
|
||||
const hasImageContentType =
|
||||
contentType && contentType.startsWith('image/');
|
||||
|
||||
// 4. 如果有图片扩展名或正确的Content-Type,则认为是有效图片
|
||||
return hasImageExtension || hasImageContentType;
|
||||
} catch (error) {
|
||||
console.warn(`图片URL验证失败: ${imageUrl}, 错误: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +139,7 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
});
|
||||
return Buffer.from(response.data, 'binary').toString('base64');
|
||||
} catch (error) {
|
||||
@@ -113,8 +148,12 @@ export abstract class BaseContentAdapter implements IContentModerationAdapter {
|
||||
}
|
||||
|
||||
// 抽象方法 - 子类必须实现
|
||||
abstract auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult>;
|
||||
abstract auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]>;
|
||||
abstract auditImage(
|
||||
auditData: ImageAuditRequest,
|
||||
): Promise<ContentAuditResult>;
|
||||
abstract auditImageBatch(
|
||||
auditDataList: ImageAuditRequest[],
|
||||
): Promise<ContentAuditResult[]>;
|
||||
abstract queryAuditResult(taskId: string): Promise<ContentAuditResult>;
|
||||
abstract handleAuditCallback(callbackData: any): Promise<void>;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
RiskLevel,
|
||||
AuditSuggestion
|
||||
AuditSuggestion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
interface DouyinAuditResponse {
|
||||
@@ -35,7 +35,10 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
private readonly douyinConfig = {
|
||||
appId: this.configService.get('BYTEDANCE_APP_ID'),
|
||||
appSecret: this.configService.get('BYTEDANCE_APP_SECRET'),
|
||||
auditApiUrl: this.configService.get('BYTEDANCE_AUDIT_API_URL', 'https://developer.toutiao.com/api/apps/v2/content/audit/image'),
|
||||
auditApiUrl: this.configService.get(
|
||||
'BYTEDANCE_AUDIT_API_URL',
|
||||
'https://developer.toutiao.com/api/apps/v2/content/audit/image',
|
||||
),
|
||||
};
|
||||
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
@@ -52,7 +55,7 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
// 3. 调用抖音审核API
|
||||
const auditResult = await this.callDouyinAuditAPI({
|
||||
...auditData,
|
||||
taskId: auditLog.taskId
|
||||
taskId: auditLog.taskId,
|
||||
});
|
||||
|
||||
// 4. 更新审核日志
|
||||
@@ -65,7 +68,9 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
|
||||
async auditImageBatch(
|
||||
auditDataList: ImageAuditRequest[],
|
||||
): Promise<ContentAuditResult[]> {
|
||||
// 抖音批量审核实现
|
||||
const results: ContentAuditResult[] = [];
|
||||
|
||||
@@ -95,7 +100,7 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
try {
|
||||
// 查询数据库中的审核结果
|
||||
const auditLog = await this.auditLogRepository.findOne({
|
||||
where: { taskId, platform: this.platform }
|
||||
where: { taskId, platform: this.platform },
|
||||
});
|
||||
|
||||
if (!auditLog) {
|
||||
@@ -147,7 +152,9 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
/**
|
||||
* 调用抖音审核API
|
||||
*/
|
||||
private async callDouyinAuditAPI(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
private async callDouyinAuditAPI(
|
||||
auditData: ImageAuditRequest,
|
||||
): Promise<ContentAuditResult> {
|
||||
const requestData = {
|
||||
app_id: this.douyinConfig.appId,
|
||||
image_url: auditData.imageUrl,
|
||||
@@ -162,8 +169,8 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 添加认证头部
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.err_no !== 0) {
|
||||
@@ -176,7 +183,9 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
/**
|
||||
* 查询抖音审核状态
|
||||
*/
|
||||
private async queryDouyinAuditStatus(taskId: string): Promise<ContentAuditResult> {
|
||||
private async queryDouyinAuditStatus(
|
||||
taskId: string,
|
||||
): Promise<ContentAuditResult> {
|
||||
const queryData = {
|
||||
app_id: this.douyinConfig.appId,
|
||||
task_id: taskId,
|
||||
@@ -186,8 +195,8 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
`${this.douyinConfig.auditApiUrl}/query`,
|
||||
queryData,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.err_no !== 0) {
|
||||
@@ -200,7 +209,10 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
/**
|
||||
* 解析抖音响应
|
||||
*/
|
||||
private parseDouyinResponse(responseData: DouyinAuditResponse, taskId: string): ContentAuditResult {
|
||||
private parseDouyinResponse(
|
||||
responseData: DouyinAuditResponse,
|
||||
taskId: string,
|
||||
): ContentAuditResult {
|
||||
const data = responseData.data;
|
||||
|
||||
return {
|
||||
@@ -221,9 +233,12 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
*/
|
||||
private mapDouyinStatus(status: number): AuditStatus {
|
||||
switch (status) {
|
||||
case 0: return AuditStatus.PROCESSING;
|
||||
case 1: return AuditStatus.COMPLETED;
|
||||
default: return AuditStatus.FAILED;
|
||||
case 0:
|
||||
return AuditStatus.PROCESSING;
|
||||
case 1:
|
||||
return AuditStatus.COMPLETED;
|
||||
default:
|
||||
return AuditStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,11 +247,15 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
*/
|
||||
private mapDouyinConclusion(conclusion: number): AuditConclusion {
|
||||
switch (conclusion) {
|
||||
case 1: return AuditConclusion.PASS;
|
||||
case 2: return AuditConclusion.REJECT;
|
||||
case 3: return AuditConclusion.REVIEW;
|
||||
case 1:
|
||||
return AuditConclusion.PASS;
|
||||
case 2:
|
||||
return AuditConclusion.REJECT;
|
||||
case 3:
|
||||
return AuditConclusion.REVIEW;
|
||||
case 4:
|
||||
default: return AuditConclusion.UNCERTAIN;
|
||||
default:
|
||||
return AuditConclusion.UNCERTAIN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,18 +263,23 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
* 映射抖音详细信息
|
||||
*/
|
||||
private mapDouyinDetails(details: any[]): any[] {
|
||||
return details?.map(detail => ({
|
||||
return (
|
||||
details?.map((detail) => ({
|
||||
type: detail.type,
|
||||
label: detail.label,
|
||||
confidence: detail.confidence,
|
||||
description: detail.description,
|
||||
})) || [];
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算风险等级
|
||||
*/
|
||||
private calculateRiskLevel(conclusion: number, confidence: number): RiskLevel {
|
||||
private calculateRiskLevel(
|
||||
conclusion: number,
|
||||
confidence: number,
|
||||
): RiskLevel {
|
||||
if (conclusion === 1) return RiskLevel.LOW;
|
||||
if (conclusion === 2 && confidence > 80) return RiskLevel.CRITICAL;
|
||||
if (conclusion === 2 && confidence > 60) return RiskLevel.HIGH;
|
||||
@@ -268,11 +292,15 @@ export class DouyinContentAdapter extends BaseContentAdapter {
|
||||
*/
|
||||
private getSuggestion(conclusion: number): AuditSuggestion {
|
||||
switch (conclusion) {
|
||||
case 1: return AuditSuggestion.PASS;
|
||||
case 2: return AuditSuggestion.BLOCK;
|
||||
case 3: return AuditSuggestion.HUMAN_REVIEW;
|
||||
case 1:
|
||||
return AuditSuggestion.PASS;
|
||||
case 2:
|
||||
return AuditSuggestion.BLOCK;
|
||||
case 3:
|
||||
return AuditSuggestion.HUMAN_REVIEW;
|
||||
case 4:
|
||||
default: return AuditSuggestion.HUMAN_REVIEW;
|
||||
default:
|
||||
return AuditSuggestion.HUMAN_REVIEW;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseContentAdapter } from './base-content.adapter';
|
||||
import {
|
||||
ImageAuditRequest,
|
||||
ContentAuditResult,
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
/**
|
||||
* 增强的基础适配器,实现平台差异抹平
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class EnhancedBaseContentAdapter extends BaseContentAdapter {
|
||||
|
||||
/**
|
||||
* 标识是否为同步平台
|
||||
*/
|
||||
abstract readonly isSyncPlatform: boolean;
|
||||
|
||||
/**
|
||||
* 统一的审核接口 - 所有平台都返回异步状态
|
||||
*/
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
try {
|
||||
// 1. 创建审核日志
|
||||
const auditLog = await this.createAuditLog(auditData);
|
||||
|
||||
// 2. 验证图片URL
|
||||
const isValidImage = await this.validateImageUrl(auditData.imageUrl);
|
||||
if (!isValidImage) {
|
||||
throw new Error('无效的图片URL');
|
||||
}
|
||||
|
||||
// 3. 调用平台特定的审核方法
|
||||
const platformResult = await this.callPlatformAuditAPI({
|
||||
...auditData,
|
||||
taskId: auditLog.taskId,
|
||||
});
|
||||
|
||||
// 4. 统一返回PROCESSING状态
|
||||
const unifiedResult: ContentAuditResult = {
|
||||
taskId: auditLog.taskId,
|
||||
status: AuditStatus.PROCESSING,
|
||||
conclusion: AuditConclusion.UNCERTAIN,
|
||||
confidence: 0,
|
||||
details: [],
|
||||
riskLevel: 'medium' as any,
|
||||
suggestion: 'human_review' as any,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// 5. 更新审核日志为处理中状态
|
||||
await this.updateAuditLog(auditLog.taskId, unifiedResult);
|
||||
|
||||
// 6. 如果是同步平台,立即模拟异步回调
|
||||
if (this.isSyncPlatform) {
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await this.handleAuditCallback(this.formatCallbackData(platformResult));
|
||||
} catch (error) {
|
||||
console.error(`模拟回调失败 (${this.platform}):`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return unifiedResult;
|
||||
} catch (error) {
|
||||
const auditError = this.handleAuditError(error, this.platform);
|
||||
throw auditError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台特定的API调用 - 子类实现
|
||||
*/
|
||||
protected abstract callPlatformAuditAPI(
|
||||
auditData: ImageAuditRequest
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* 格式化平台结果为回调数据格式 - 同步平台需要
|
||||
*/
|
||||
protected abstract formatCallbackData(platformResult: any): any;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EnhancedBaseContentAdapter } from './enhanced-base-content.adapter';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import {
|
||||
ImageAuditRequest,
|
||||
ContentAuditResult,
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
/**
|
||||
* 增强的微信审核适配器 - 同步转异步
|
||||
*/
|
||||
@Injectable()
|
||||
export class EnhancedWechatContentAdapter extends EnhancedBaseContentAdapter {
|
||||
platform = PlatformType.WECHAT;
|
||||
readonly isSyncPlatform = true; // 🎯 标识为同步平台
|
||||
|
||||
private readonly wechatConfig = {
|
||||
appId: this.configService.get('WECHAT_APP_ID'),
|
||||
appSecret: this.configService.get('WECHAT_APP_SECRET'),
|
||||
auditApiUrl: this.configService.get(
|
||||
'WECHAT_AUDIT_API_URL',
|
||||
'https://api.weixin.qq.com/wxa/img_sec_check',
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* 调用微信同步API,但适配为异步模式
|
||||
*/
|
||||
protected async callPlatformAuditAPI(auditData: ImageAuditRequest): Promise<any> {
|
||||
// 1. 获取微信Access Token
|
||||
const accessToken = await this.getWechatAccessToken();
|
||||
|
||||
// 2. 获取图片内容
|
||||
const imageBuffer = await this.getImageBuffer(auditData.imageUrl);
|
||||
|
||||
// 3. 调用微信审核API (同步)
|
||||
const response = await this.httpService.axiosRef.post(
|
||||
`${this.wechatConfig.auditApiUrl}?access_token=${accessToken}`,
|
||||
{
|
||||
media: imageBuffer.toString('base64'),
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.errcode !== 0) {
|
||||
throw new Error(`微信审核API错误: ${response.data.errmsg}`);
|
||||
}
|
||||
|
||||
// 4. 返回微信的同步结果
|
||||
return {
|
||||
errcode: response.data.errcode,
|
||||
errmsg: response.data.errmsg,
|
||||
result: response.data.result || {},
|
||||
taskId: auditData.taskId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将微信同步结果格式化为回调数据
|
||||
*/
|
||||
protected formatCallbackData(platformResult: any): any {
|
||||
const isPass = platformResult.errcode === 0 &&
|
||||
(!platformResult.result.suggest || platformResult.result.suggest === 'pass');
|
||||
|
||||
return {
|
||||
task_id: platformResult.taskId,
|
||||
status: 1, // 完成
|
||||
conclusion: isPass ? 1 : 2, // 1-通过, 2-拒绝
|
||||
confidence: isPass ? 95 : 85,
|
||||
details: platformResult.result.detail || [],
|
||||
platform_data: platformResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理回调 - 统一的异步处理
|
||||
*/
|
||||
async handleAuditCallback(callbackData: any): Promise<void> {
|
||||
try {
|
||||
const { task_id, status, conclusion, confidence, details } = callbackData;
|
||||
|
||||
const auditResult: ContentAuditResult = {
|
||||
taskId: task_id,
|
||||
status: this.mapWechatStatus(status),
|
||||
conclusion: this.mapWechatConclusion(conclusion),
|
||||
confidence: confidence,
|
||||
details: this.mapWechatDetails(details),
|
||||
riskLevel: this.calculateRiskLevel(conclusion, confidence),
|
||||
suggestion: this.getSuggestion(conclusion),
|
||||
platformData: callbackData,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// 更新数据库
|
||||
await this.updateAuditLog(task_id, auditResult);
|
||||
|
||||
// 🎯 触发业务回调 - 通知模板执行系统
|
||||
await this.notifyTemplateExecution(task_id, auditResult);
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理微信审核回调失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知模板执行系统 - 关键集成点
|
||||
*/
|
||||
private async notifyTemplateExecution(taskId: string, auditResult: ContentAuditResult): Promise<void> {
|
||||
try {
|
||||
// 发布事件或调用模板执行服务
|
||||
// 这里可以用事件总线、消息队列等
|
||||
console.log(`微信审核完成,通知模板执行: ${taskId}`, auditResult.conclusion);
|
||||
|
||||
// TODO: 调用模板执行服务的回调处理方法
|
||||
// await this.templateExecutionService.handleAuditComplete(taskId, auditResult);
|
||||
} catch (error) {
|
||||
console.error('通知模板执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 其他辅助方法...
|
||||
private async getWechatAccessToken(): Promise<string> {
|
||||
// 实现微信Access Token获取逻辑
|
||||
// 可以加缓存机制
|
||||
return 'mock_access_token';
|
||||
}
|
||||
|
||||
private async getImageBuffer(imageUrl: string): Promise<Buffer> {
|
||||
const response = await this.httpService.axiosRef.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
|
||||
private mapWechatStatus(status: number): AuditStatus {
|
||||
return status === 1 ? AuditStatus.COMPLETED : AuditStatus.FAILED;
|
||||
}
|
||||
|
||||
private mapWechatConclusion(conclusion: number): AuditConclusion {
|
||||
switch (conclusion) {
|
||||
case 1: return AuditConclusion.PASS;
|
||||
case 2: return AuditConclusion.REJECT;
|
||||
default: return AuditConclusion.UNCERTAIN;
|
||||
}
|
||||
}
|
||||
|
||||
private mapWechatDetails(details: any[]): any[] {
|
||||
return details?.map(detail => ({
|
||||
type: detail.type || 'unknown',
|
||||
label: detail.label || 'unknown',
|
||||
confidence: detail.confidence || 0,
|
||||
description: detail.msg || '内容不当',
|
||||
})) || [];
|
||||
}
|
||||
|
||||
private calculateRiskLevel(conclusion: number, confidence: number): any {
|
||||
if (conclusion === 1) return 'low';
|
||||
if (conclusion === 2 && confidence > 80) return 'high';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
private getSuggestion(conclusion: number): any {
|
||||
switch (conclusion) {
|
||||
case 1: return 'pass';
|
||||
case 2: return 'block';
|
||||
default: return 'human_review';
|
||||
}
|
||||
}
|
||||
|
||||
// 批量审核等其他方法保持原有逻辑...
|
||||
async auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
|
||||
const results: ContentAuditResult[] = [];
|
||||
for (const auditData of auditDataList) {
|
||||
try {
|
||||
const result = await this.auditImage(auditData);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
taskId: auditData.taskId || this.generateTaskId(),
|
||||
status: AuditStatus.FAILED,
|
||||
conclusion: AuditConclusion.UNCERTAIN,
|
||||
confidence: 0,
|
||||
details: [],
|
||||
riskLevel: 'medium' as any,
|
||||
suggestion: 'human_review' as any,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async queryAuditResult(taskId: string): Promise<ContentAuditResult> {
|
||||
// 查询数据库中的结果
|
||||
const auditLog = await this.auditLogRepository.findOne({
|
||||
where: { taskId, platform: this.platform }
|
||||
});
|
||||
|
||||
if (!auditLog) {
|
||||
throw new Error('审核任务不存在');
|
||||
}
|
||||
|
||||
return auditLog.auditResult;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
RiskLevel,
|
||||
AuditSuggestion
|
||||
AuditSuggestion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
interface WechatAuditResponse {
|
||||
@@ -34,7 +34,10 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
private readonly wechatConfig = {
|
||||
appId: this.configService.get('WECHAT_APP_ID'),
|
||||
appSecret: this.configService.get('WECHAT_APP_SECRET'),
|
||||
auditApiUrl: this.configService.get('WECHAT_AUDIT_API_URL', 'https://api.weixin.qq.com/wxa/img_sec_check'),
|
||||
auditApiUrl: this.configService.get(
|
||||
'WECHAT_AUDIT_API_URL',
|
||||
'https://api.weixin.qq.com/wxa/img_sec_check',
|
||||
),
|
||||
};
|
||||
|
||||
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
@@ -51,7 +54,7 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
// 3. 调用微信审核API
|
||||
const auditResult = await this.callWechatAuditAPI({
|
||||
...auditData,
|
||||
taskId: auditLog.taskId
|
||||
taskId: auditLog.taskId,
|
||||
});
|
||||
|
||||
// 4. 更新审核日志
|
||||
@@ -64,7 +67,9 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
|
||||
async auditImageBatch(
|
||||
auditDataList: ImageAuditRequest[],
|
||||
): Promise<ContentAuditResult[]> {
|
||||
// 微信批量审核实现
|
||||
const results: ContentAuditResult[] = [];
|
||||
|
||||
@@ -94,7 +99,7 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
try {
|
||||
// 微信审核是同步的,直接从数据库查询
|
||||
const auditLog = await this.auditLogRepository.findOne({
|
||||
where: { taskId, platform: this.platform }
|
||||
where: { taskId, platform: this.platform },
|
||||
});
|
||||
|
||||
if (!auditLog) {
|
||||
@@ -115,7 +120,9 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
/**
|
||||
* 调用微信审核API
|
||||
*/
|
||||
private async callWechatAuditAPI(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
private async callWechatAuditAPI(
|
||||
auditData: ImageAuditRequest,
|
||||
): Promise<ContentAuditResult> {
|
||||
// 获取access_token
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
@@ -123,7 +130,11 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
const imageBuffer = await this.getImageBuffer(auditData.imageUrl);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('media', new Blob([new Uint8Array(imageBuffer)]), 'image.jpg');
|
||||
formData.append(
|
||||
'media',
|
||||
new Blob([new Uint8Array(imageBuffer)]),
|
||||
'image.jpg',
|
||||
);
|
||||
|
||||
const response = await this.httpService.axiosRef.post(
|
||||
`${this.wechatConfig.auditApiUrl}?access_token=${accessToken}`,
|
||||
@@ -131,8 +142,8 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errcode !== 0) {
|
||||
@@ -164,7 +175,7 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
});
|
||||
return Buffer.from(response.data);
|
||||
} catch (error) {
|
||||
@@ -175,7 +186,10 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
/**
|
||||
* 解析微信响应
|
||||
*/
|
||||
private parseWechatResponse(responseData: WechatAuditResponse, taskId: string): ContentAuditResult {
|
||||
private parseWechatResponse(
|
||||
responseData: WechatAuditResponse,
|
||||
taskId: string,
|
||||
): ContentAuditResult {
|
||||
const result = responseData.result;
|
||||
|
||||
return {
|
||||
@@ -196,10 +210,14 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
*/
|
||||
private mapWechatConclusion(suggest: string): AuditConclusion {
|
||||
switch (suggest) {
|
||||
case 'pass': return AuditConclusion.PASS;
|
||||
case 'risky': return AuditConclusion.REJECT;
|
||||
case 'review': return AuditConclusion.REVIEW;
|
||||
default: return AuditConclusion.UNCERTAIN;
|
||||
case 'pass':
|
||||
return AuditConclusion.PASS;
|
||||
case 'risky':
|
||||
return AuditConclusion.REJECT;
|
||||
case 'review':
|
||||
return AuditConclusion.REVIEW;
|
||||
default:
|
||||
return AuditConclusion.UNCERTAIN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,19 +228,21 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
if (!details || details.length === 0) return 100;
|
||||
|
||||
// 取最高置信度
|
||||
return Math.max(...details.map(d => d.prob || 0));
|
||||
return Math.max(...details.map((d) => d.prob || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射微信详细信息
|
||||
*/
|
||||
private mapWechatDetails(details: any[]): any[] {
|
||||
return details?.map(detail => ({
|
||||
return (
|
||||
details?.map((detail) => ({
|
||||
type: detail.strategy || 'unknown',
|
||||
label: this.getLabelDescription(detail.label),
|
||||
confidence: detail.prob || 0,
|
||||
description: `${detail.strategy}: ${this.getLabelDescription(detail.label)}`,
|
||||
})) || [];
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,10 +285,14 @@ export class WechatContentAdapter extends BaseContentAdapter {
|
||||
*/
|
||||
private getWechatSuggestion(suggest: string): AuditSuggestion {
|
||||
switch (suggest) {
|
||||
case 'pass': return AuditSuggestion.PASS;
|
||||
case 'risky': return AuditSuggestion.BLOCK;
|
||||
case 'review': return AuditSuggestion.HUMAN_REVIEW;
|
||||
default: return AuditSuggestion.HUMAN_REVIEW;
|
||||
case 'pass':
|
||||
return AuditSuggestion.PASS;
|
||||
case 'risky':
|
||||
return AuditSuggestion.BLOCK;
|
||||
case 'review':
|
||||
return AuditSuggestion.HUMAN_REVIEW;
|
||||
default:
|
||||
return AuditSuggestion.HUMAN_REVIEW;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,13 +46,7 @@ import { ContentAuditGuard } from './guards/content-audit.guard';
|
||||
// 守卫
|
||||
ContentAuditGuard,
|
||||
],
|
||||
controllers: [
|
||||
ContentModerationController,
|
||||
],
|
||||
exports: [
|
||||
UnifiedContentService,
|
||||
ContentAdapterFactory,
|
||||
ContentAuditGuard,
|
||||
],
|
||||
controllers: [ContentModerationController],
|
||||
exports: [UnifiedContentService, ContentAdapterFactory, ContentAuditGuard],
|
||||
})
|
||||
export class ContentModerationModule {}
|
||||
@@ -1,15 +1,31 @@
|
||||
import { Controller, Post, Body, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { UnifiedContentService } from '../services/unified-content.service';
|
||||
import { ImageAuditDto } from '../dto/image-audit.dto';
|
||||
import {
|
||||
ContentAuditResponseDto,
|
||||
BatchAuditResponseDto,
|
||||
AuditStatsResponseDto
|
||||
AuditStatsResponseDto,
|
||||
} from '../dto/audit-response.dto';
|
||||
import { PlatformAuthGuard } from '../../platform/guards/platform-auth.guard';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import { ResponseUtil, ApiResponse as ApiResponseType } from '../../utils/response.util';
|
||||
import {
|
||||
ResponseUtil,
|
||||
ApiResponse as ApiResponseType,
|
||||
} from '../../utils/response.util';
|
||||
|
||||
// 模拟当前用户装饰器,实际应从现有项目中导入
|
||||
const CurrentUser = () => {
|
||||
@@ -21,26 +37,31 @@ const CurrentUser = () => {
|
||||
@ApiTags('内容审核')
|
||||
@Controller('api/v1/content-moderation')
|
||||
export class ContentModerationController {
|
||||
constructor(
|
||||
private readonly unifiedContentService: UnifiedContentService,
|
||||
) {}
|
||||
constructor(private readonly unifiedContentService: UnifiedContentService) {}
|
||||
|
||||
@Post(':platform/audit-image')
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '图片内容审核' })
|
||||
@ApiResponse({ status: 200, description: '审核成功', type: ContentAuditResponseDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '审核成功',
|
||||
type: ContentAuditResponseDto,
|
||||
})
|
||||
async auditImage(
|
||||
@Param('platform') platform: PlatformType,
|
||||
@Body() auditDto: ImageAuditDto,
|
||||
@CurrentUser() user: any
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
const auditData = {
|
||||
...auditDto,
|
||||
userId: user.userId,
|
||||
};
|
||||
|
||||
const result = await this.unifiedContentService.auditImage(platform, auditData);
|
||||
const result = await this.unifiedContentService.auditImage(
|
||||
platform,
|
||||
auditData,
|
||||
);
|
||||
|
||||
return ResponseUtil.success(result, '审核提交成功');
|
||||
}
|
||||
@@ -49,40 +70,57 @@ export class ContentModerationController {
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '批量图片审核' })
|
||||
@ApiResponse({ status: 200, description: '批量审核成功', type: BatchAuditResponseDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '批量审核成功',
|
||||
type: BatchAuditResponseDto,
|
||||
})
|
||||
async auditImageBatch(
|
||||
@Param('platform') platform: PlatformType,
|
||||
@Body() auditDtoList: ImageAuditDto[],
|
||||
@CurrentUser() user: any
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
const auditDataList = auditDtoList.map(dto => ({
|
||||
const auditDataList = auditDtoList.map((dto) => ({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
}));
|
||||
|
||||
const results = await this.unifiedContentService.auditImageBatch(platform, auditDataList);
|
||||
const results = await this.unifiedContentService.auditImageBatch(
|
||||
platform,
|
||||
auditDataList,
|
||||
);
|
||||
|
||||
const successCount = results.filter(r => r.status === 'completed').length;
|
||||
const successCount = results.filter((r) => r.status === 'completed').length;
|
||||
const failureCount = results.length - successCount;
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
totalCount: results.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
}, '批量审核提交成功');
|
||||
},
|
||||
'批量审核提交成功',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':platform/result/:taskId')
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询审核结果' })
|
||||
@ApiResponse({ status: 200, description: '查询成功', type: ContentAuditResponseDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: ContentAuditResponseDto,
|
||||
})
|
||||
async getAuditResult(
|
||||
@Param('platform') platform: PlatformType,
|
||||
@Param('taskId') taskId: string
|
||||
@Param('taskId') taskId: string,
|
||||
) {
|
||||
const result = await this.unifiedContentService.queryAuditResult(platform, taskId);
|
||||
const result = await this.unifiedContentService.queryAuditResult(
|
||||
platform,
|
||||
taskId,
|
||||
);
|
||||
|
||||
return ResponseUtil.success(result, '查询成功');
|
||||
}
|
||||
@@ -91,9 +129,12 @@ export class ContentModerationController {
|
||||
@ApiOperation({ summary: '审核结果回调' })
|
||||
async handleCallback(
|
||||
@Param('platform') platform: PlatformType,
|
||||
@Body() callbackData: any
|
||||
@Body() callbackData: any,
|
||||
) {
|
||||
await this.unifiedContentService.handleAuditCallback(platform, callbackData);
|
||||
await this.unifiedContentService.handleAuditCallback(
|
||||
platform,
|
||||
callbackData,
|
||||
);
|
||||
|
||||
return ResponseUtil.success(null, '回调处理成功');
|
||||
}
|
||||
@@ -102,48 +143,69 @@ export class ContentModerationController {
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取审核历史' })
|
||||
async getAuditHistory(
|
||||
@CurrentUser() user: any,
|
||||
@Query('limit') limit = 50
|
||||
) {
|
||||
const history = await this.unifiedContentService.getUserAuditHistory(user.userId, limit);
|
||||
async getAuditHistory(@CurrentUser() user: any, @Query('limit') limit = 50) {
|
||||
const history = await this.unifiedContentService.getUserAuditHistory(
|
||||
user.userId,
|
||||
limit,
|
||||
);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
total: history.length,
|
||||
list: history,
|
||||
}, '获取成功');
|
||||
},
|
||||
'获取成功',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: '获取审核统计' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AuditStatsResponseDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
type: AuditStatsResponseDto,
|
||||
})
|
||||
async getAuditStats(
|
||||
@Query('platform') platform?: PlatformType,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string
|
||||
@Query('endDate') endDate?: string,
|
||||
) {
|
||||
const start = startDate ? new Date(startDate) : undefined;
|
||||
const end = endDate ? new Date(endDate) : undefined;
|
||||
|
||||
const stats = await this.unifiedContentService.getAuditStats(platform, start, end);
|
||||
const stats = await this.unifiedContentService.getAuditStats(
|
||||
platform,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
|
||||
// 处理统计数据
|
||||
const totalAudits = stats.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
|
||||
const passCount = stats.filter((item: any) => item.conclusion === 'pass')
|
||||
const totalAudits = stats.reduce(
|
||||
(sum: number, item: any) => sum + parseInt(item.count),
|
||||
0,
|
||||
);
|
||||
const passCount = stats
|
||||
.filter((item: any) => item.conclusion === 'pass')
|
||||
.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
|
||||
const rejectCount = stats.filter((item: any) => item.conclusion === 'reject')
|
||||
const rejectCount = stats
|
||||
.filter((item: any) => item.conclusion === 'reject')
|
||||
.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
|
||||
const reviewCount = stats.filter((item: any) => item.conclusion === 'review')
|
||||
const reviewCount = stats
|
||||
.filter((item: any) => item.conclusion === 'review')
|
||||
.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
|
||||
|
||||
const avgConfidence = stats.reduce((sum: number, item: any) =>
|
||||
sum + (parseFloat(item.avgConfidence) || 0), 0) / (stats.length || 1);
|
||||
const avgConfidence =
|
||||
stats.reduce(
|
||||
(sum: number, item: any) => sum + (parseFloat(item.avgConfidence) || 0),
|
||||
0,
|
||||
) / (stats.length || 1);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
totalAudits,
|
||||
passRate: totalAudits > 0 ? (passCount / totalAudits * 100) : 0,
|
||||
rejectRate: totalAudits > 0 ? (rejectCount / totalAudits * 100) : 0,
|
||||
reviewRate: totalAudits > 0 ? (reviewCount / totalAudits * 100) : 0,
|
||||
passRate: totalAudits > 0 ? (passCount / totalAudits) * 100 : 0,
|
||||
rejectRate: totalAudits > 0 ? (rejectCount / totalAudits) * 100 : 0,
|
||||
reviewRate: totalAudits > 0 ? (reviewCount / totalAudits) * 100 : 0,
|
||||
averageConfidence: avgConfidence,
|
||||
platformStats: stats.map((item: any) => ({
|
||||
platform: item.platform,
|
||||
@@ -151,18 +213,20 @@ export class ContentModerationController {
|
||||
conclusion: item.conclusion,
|
||||
avgConfidence: parseFloat(item.avgConfidence) || 0,
|
||||
})),
|
||||
}, '获取成功');
|
||||
},
|
||||
'获取成功',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('user-stats')
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取用户审核统计' })
|
||||
async getUserAuditStats(
|
||||
@CurrentUser() user: any,
|
||||
@Query('days') days = 30
|
||||
) {
|
||||
const stats = await this.unifiedContentService.getUserAuditStats(user.userId, days);
|
||||
async getUserAuditStats(@CurrentUser() user: any, @Query('days') days = 30) {
|
||||
const stats = await this.unifiedContentService.getUserAuditStats(
|
||||
user.userId,
|
||||
days,
|
||||
);
|
||||
|
||||
return ResponseUtil.success(stats, '获取成功');
|
||||
}
|
||||
@@ -178,11 +242,15 @@ export class ContentModerationController {
|
||||
@Get('check/:taskId')
|
||||
@ApiOperation({ summary: '检查内容是否通过审核' })
|
||||
async checkContentApproval(@Param('taskId') taskId: string) {
|
||||
const isApproved = await this.unifiedContentService.isContentApproved(taskId);
|
||||
const isApproved =
|
||||
await this.unifiedContentService.isContentApproved(taskId);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
taskId,
|
||||
approved: isApproved,
|
||||
}, '检查成功');
|
||||
},
|
||||
'检查成功',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
RiskLevel,
|
||||
AuditSuggestion
|
||||
AuditSuggestion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
export class AuditDetailResponseDto {
|
||||
|
||||
@@ -4,14 +4,14 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import {
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
RiskLevel,
|
||||
AuditSuggestion
|
||||
AuditSuggestion,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
|
||||
@Entity('content_audit_logs')
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UnifiedContentService } from '../services/unified-content.service';
|
||||
|
||||
@@ -18,7 +23,8 @@ export class ContentAuditGuard implements CanActivate {
|
||||
}
|
||||
|
||||
try {
|
||||
const isApproved = await this.unifiedContentService.isContentApproved(taskId);
|
||||
const isApproved =
|
||||
await this.unifiedContentService.isContentApproved(taskId);
|
||||
|
||||
if (!isApproved) {
|
||||
throw new ForbiddenException('内容审核未通过,无法访问');
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { AuditStatus, AuditConclusion, RiskLevel, AuditSuggestion } from './content-moderation.interface';
|
||||
import {
|
||||
AuditStatus,
|
||||
AuditConclusion,
|
||||
RiskLevel,
|
||||
AuditSuggestion,
|
||||
} from './content-moderation.interface';
|
||||
|
||||
export interface AuditResultResponse {
|
||||
code: number;
|
||||
|
||||
@@ -7,7 +7,9 @@ export interface IContentModerationAdapter {
|
||||
auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult>;
|
||||
|
||||
// 批量图片审核
|
||||
auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]>;
|
||||
auditImageBatch(
|
||||
auditDataList: ImageAuditRequest[],
|
||||
): Promise<ContentAuditResult[]>;
|
||||
|
||||
// 查询审核结果
|
||||
queryAuditResult(taskId: string): Promise<ContentAuditResult>;
|
||||
@@ -42,28 +44,28 @@ export enum AuditStatus {
|
||||
PROCESSING = 'processing', // 审核中
|
||||
COMPLETED = 'completed', // 审核完成
|
||||
FAILED = 'failed', // 审核失败
|
||||
TIMEOUT = 'timeout' // 审核超时
|
||||
TIMEOUT = 'timeout', // 审核超时
|
||||
}
|
||||
|
||||
export enum AuditConclusion {
|
||||
PASS = 'pass', // 通过
|
||||
REJECT = 'reject', // 拒绝
|
||||
REVIEW = 'review', // 人工复审
|
||||
UNCERTAIN = 'uncertain' // 不确定
|
||||
UNCERTAIN = 'uncertain', // 不确定
|
||||
}
|
||||
|
||||
export enum RiskLevel {
|
||||
LOW = 'low', // 低风险
|
||||
MEDIUM = 'medium', // 中风险
|
||||
HIGH = 'high', // 高风险
|
||||
CRITICAL = 'critical' // 极高风险
|
||||
CRITICAL = 'critical', // 极高风险
|
||||
}
|
||||
|
||||
export enum AuditSuggestion {
|
||||
PASS = 'pass', // 建议通过
|
||||
BLOCK = 'block', // 建议拦截
|
||||
HUMAN_REVIEW = 'human_review', // 建议人工审核
|
||||
DELETE = 'delete' // 建议删除
|
||||
DELETE = 'delete', // 建议删除
|
||||
}
|
||||
|
||||
export interface AuditDetail {
|
||||
@@ -71,7 +73,8 @@ export interface AuditDetail {
|
||||
label: string; // 具体标签
|
||||
confidence: number; // 该项置信度
|
||||
description: string; // 描述信息
|
||||
position?: { // 位置信息(如果支持)
|
||||
position?: {
|
||||
// 位置信息(如果支持)
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
|
||||
@@ -6,7 +6,10 @@ import { WechatContentAdapter } from '../adapters/wechat-content.adapter';
|
||||
|
||||
@Injectable()
|
||||
export class ContentAdapterFactory {
|
||||
private readonly adapters = new Map<PlatformType, IContentModerationAdapter>();
|
||||
private readonly adapters = new Map<
|
||||
PlatformType,
|
||||
IContentModerationAdapter
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly douyinAdapter: DouyinContentAdapter,
|
||||
@@ -45,7 +48,10 @@ export class ContentAdapterFactory {
|
||||
/**
|
||||
* 注册新的审核适配器(用于动态扩展)
|
||||
*/
|
||||
registerAdapter(platform: PlatformType, adapter: IContentModerationAdapter): void {
|
||||
registerAdapter(
|
||||
platform: PlatformType,
|
||||
adapter: IContentModerationAdapter,
|
||||
): void {
|
||||
this.adapters.set(platform, adapter);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { ContentAuditLogEntity } from '../entities/content-audit-log.entity';
|
||||
import {
|
||||
ImageAuditRequest,
|
||||
ContentAuditResult,
|
||||
AuditStatus
|
||||
AuditStatus,
|
||||
} from '../interfaces/content-moderation.interface';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
|
||||
@@ -21,7 +21,10 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 统一图片审核接口
|
||||
*/
|
||||
async auditImage(platform: PlatformType, auditData: ImageAuditRequest): Promise<ContentAuditResult> {
|
||||
async auditImage(
|
||||
platform: PlatformType,
|
||||
auditData: ImageAuditRequest,
|
||||
): Promise<ContentAuditResult> {
|
||||
const adapter = this.contentAdapterFactory.getAdapter(platform);
|
||||
return adapter.auditImage(auditData);
|
||||
}
|
||||
@@ -29,7 +32,10 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 批量图片审核
|
||||
*/
|
||||
async auditImageBatch(platform: PlatformType, auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
|
||||
async auditImageBatch(
|
||||
platform: PlatformType,
|
||||
auditDataList: ImageAuditRequest[],
|
||||
): Promise<ContentAuditResult[]> {
|
||||
const adapter = this.contentAdapterFactory.getAdapter(platform);
|
||||
return adapter.auditImageBatch(auditDataList);
|
||||
}
|
||||
@@ -37,7 +43,10 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 查询审核结果
|
||||
*/
|
||||
async queryAuditResult(platform: PlatformType, taskId: string): Promise<ContentAuditResult> {
|
||||
async queryAuditResult(
|
||||
platform: PlatformType,
|
||||
taskId: string,
|
||||
): Promise<ContentAuditResult> {
|
||||
const adapter = this.contentAdapterFactory.getAdapter(platform);
|
||||
return adapter.queryAuditResult(taskId);
|
||||
}
|
||||
@@ -45,7 +54,10 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 处理审核回调
|
||||
*/
|
||||
async handleAuditCallback(platform: PlatformType, callbackData: any): Promise<void> {
|
||||
async handleAuditCallback(
|
||||
platform: PlatformType,
|
||||
callbackData: any,
|
||||
): Promise<void> {
|
||||
const adapter = this.contentAdapterFactory.getAdapter(platform);
|
||||
return adapter.handleAuditCallback(callbackData);
|
||||
}
|
||||
@@ -53,7 +65,10 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 获取用户审核历史
|
||||
*/
|
||||
async getUserAuditHistory(userId: string, limit = 50): Promise<ContentAuditLogEntity[]> {
|
||||
async getUserAuditHistory(
|
||||
userId: string,
|
||||
limit = 50,
|
||||
): Promise<ContentAuditLogEntity[]> {
|
||||
return this.auditLogRepository.find({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
@@ -64,7 +79,11 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 获取审核统计
|
||||
*/
|
||||
async getAuditStats(platform?: PlatformType, startDate?: Date, endDate?: Date): Promise<any> {
|
||||
async getAuditStats(
|
||||
platform?: PlatformType,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<any> {
|
||||
const queryBuilder = this.auditLogRepository
|
||||
.createQueryBuilder('audit')
|
||||
.select('audit.platform', 'platform')
|
||||
@@ -101,7 +120,7 @@ export class UnifiedContentService {
|
||||
*/
|
||||
async isContentApproved(taskId: string): Promise<boolean> {
|
||||
const auditLog = await this.auditLogRepository.findOne({
|
||||
where: { taskId }
|
||||
where: { taskId },
|
||||
});
|
||||
|
||||
if (!auditLog) {
|
||||
@@ -114,7 +133,10 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 获取审核详细统计数据
|
||||
*/
|
||||
async getDetailedAuditStats(platform?: PlatformType, days = 30): Promise<any> {
|
||||
async getDetailedAuditStats(
|
||||
platform?: PlatformType,
|
||||
days = 30,
|
||||
): Promise<any> {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
|
||||
@@ -137,7 +159,7 @@ export class UnifiedContentService {
|
||||
|
||||
// 处理统计数据
|
||||
const statsMap = new Map();
|
||||
results.forEach(item => {
|
||||
results.forEach((item) => {
|
||||
const key = `${item.date}_${item.platform}`;
|
||||
if (!statsMap.has(key)) {
|
||||
statsMap.set(key, {
|
||||
@@ -176,14 +198,20 @@ export class UnifiedContentService {
|
||||
.groupBy('audit.conclusion')
|
||||
.getRawMany();
|
||||
|
||||
const totalCount = stats.reduce((sum, stat) => sum + parseInt(stat.count), 0);
|
||||
const totalCount = stats.reduce(
|
||||
(sum, stat) => sum + parseInt(stat.count),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalAudits: totalCount,
|
||||
stats: stats.map(stat => ({
|
||||
stats: stats.map((stat) => ({
|
||||
conclusion: stat.conclusion,
|
||||
count: parseInt(stat.count),
|
||||
percentage: totalCount > 0 ? (parseInt(stat.count) / totalCount * 100).toFixed(2) : '0.00',
|
||||
percentage:
|
||||
totalCount > 0
|
||||
? ((parseInt(stat.count) / totalCount) * 100).toFixed(2)
|
||||
: '0.00',
|
||||
avgConfidence: parseFloat(stat.avgConfidence || '0').toFixed(2),
|
||||
})),
|
||||
};
|
||||
@@ -192,16 +220,19 @@ export class UnifiedContentService {
|
||||
/**
|
||||
* 批量更新审核状态(用于异步任务处理)
|
||||
*/
|
||||
async batchUpdateAuditStatus(updates: Array<{taskId: string, status: AuditStatus, result?: any}>): Promise<void> {
|
||||
const updatePromises = updates.map(update =>
|
||||
async batchUpdateAuditStatus(
|
||||
updates: Array<{ taskId: string; status: AuditStatus; result?: any }>,
|
||||
): Promise<void> {
|
||||
const updatePromises = updates.map((update) =>
|
||||
this.auditLogRepository.update(
|
||||
{ taskId: update.taskId },
|
||||
{
|
||||
status: update.status,
|
||||
auditResult: update.result,
|
||||
completedAt: update.status === AuditStatus.COMPLETED ? new Date() : undefined,
|
||||
}
|
||||
)
|
||||
completedAt:
|
||||
update.status === AuditStatus.COMPLETED ? new Date() : undefined,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
328
src/controllers/enhanced-template.controller.ts
Normal file
328
src/controllers/enhanced-template.controller.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { N8nTemplateFactoryService } from '../services/n8n-template-factory.service';
|
||||
import { UnifiedContentService } from '../content-moderation/services/unified-content.service';
|
||||
import { PlatformAuthGuard } from 'src/platform/guards/platform-auth.guard';
|
||||
import { ResponseUtil, ApiResponse as ApiResponseType } from '../utils/response.util';
|
||||
import { AuditStatus } from '../content-moderation/interfaces/content-moderation.interface';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
TemplateExecutionEntity,
|
||||
ExecutionStatus,
|
||||
ExecutionType,
|
||||
} from '../entities/template-execution.entity';
|
||||
import { N8nTemplateEntity } from '../entities/n8n-template.entity';
|
||||
|
||||
/**
|
||||
* 增强的模板控制器 - 支持统一异步审核
|
||||
*/
|
||||
@ApiTags('AI模板系统 (增强版)')
|
||||
@Controller('enhanced/templates')
|
||||
export class EnhancedTemplateController {
|
||||
constructor(
|
||||
private readonly templateFactory: N8nTemplateFactoryService,
|
||||
@InjectRepository(TemplateExecutionEntity)
|
||||
private readonly executionRepository: Repository<TemplateExecutionEntity>,
|
||||
private readonly unifiedContentService: UnifiedContentService,
|
||||
) {}
|
||||
|
||||
@Post('code/:code/execute')
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiOperation({
|
||||
summary: '异步执行模板 (增强版)',
|
||||
description: '统一异步模式:先提交审核,然后通过回调继续执行模板。支持实时状态查询。',
|
||||
})
|
||||
async executeTemplateByCode(
|
||||
@Param('code') code: string,
|
||||
@Body() body: { imageUrl: string },
|
||||
@Request() req,
|
||||
): Promise<ApiResponseType<{
|
||||
executionId: number;
|
||||
auditTaskId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}>> {
|
||||
try {
|
||||
const { imageUrl } = body;
|
||||
const userId = req.user.userId;
|
||||
const platform = req.user.platform;
|
||||
|
||||
// 1. 参数验证
|
||||
if (!imageUrl) {
|
||||
throw new HttpException('imageUrl is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// 2. 检查用户任务限制
|
||||
await this.checkUserTaskLimit(userId);
|
||||
|
||||
// 3. 获取模板配置
|
||||
const templateConfig = await this.templateFactory.getTemplateByCode(code);
|
||||
if (!templateConfig) {
|
||||
throw new HttpException('Template not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// 4. 🎯 提交图片审核 (统一异步模式)
|
||||
const auditResult = await this.unifiedContentService.auditImage(platform, {
|
||||
imageUrl,
|
||||
userId,
|
||||
businessType: 'template_execution',
|
||||
extraData: {
|
||||
templateId: templateConfig.id,
|
||||
templateCode: code,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 创建模板执行记录 (待审核状态)
|
||||
const execution = this.executionRepository.create({
|
||||
templateId: templateConfig.id,
|
||||
userId,
|
||||
platform: req.user.platform,
|
||||
type: templateConfig.templateType === 'video' ? ExecutionType.VIDEO : ExecutionType.IMAGE,
|
||||
inputImageUrl: imageUrl,
|
||||
auditTaskId: auditResult.taskId, // 🎯 关联审核任务
|
||||
status: ExecutionStatus.PENDING_AUDIT, // 🎯 新增状态:待审核
|
||||
progress: 0,
|
||||
creditCost: templateConfig.creditCost,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
const savedExecution = await this.executionRepository.save(execution);
|
||||
|
||||
// 6. 返回执行ID和审核状态
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
executionId: savedExecution.id,
|
||||
auditTaskId: auditResult.taskId,
|
||||
status: 'pending_audit',
|
||||
message: '图片审核中,请查询执行进度获取最新状态',
|
||||
},
|
||||
'模板执行已提交,正在进行图片审核'
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('模板执行提交失败:', error);
|
||||
throw new HttpException(
|
||||
error.message || 'Template execution failed',
|
||||
error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 审核完成回调处理 - 关键方法
|
||||
*/
|
||||
async handleAuditComplete(auditTaskId: string, auditResult: any): Promise<void> {
|
||||
try {
|
||||
// 1. 查找对应的执行记录
|
||||
const execution = await this.executionRepository.findOne({
|
||||
where: { auditTaskId },
|
||||
relations: ['template'],
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
console.error(`未找到审核任务对应的执行记录: ${auditTaskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 根据审核结果决定后续处理
|
||||
if (auditResult.conclusion === 'pass') {
|
||||
// 🎯 审核通过,开始执行模板
|
||||
await this.startTemplateExecution(execution);
|
||||
} else {
|
||||
// 🎯 审核不通过,更新状态为审核失败
|
||||
await this.updateExecutionStatus(
|
||||
execution.id,
|
||||
ExecutionStatus.AUDIT_FAILED,
|
||||
`图片审核未通过: ${auditResult.details.map(d => d.description).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理审核完成回调失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始模板执行
|
||||
*/
|
||||
private async startTemplateExecution(execution: TemplateExecutionEntity): Promise<void> {
|
||||
try {
|
||||
// 1. 更新状态为执行中
|
||||
await this.updateExecutionStatus(execution.id, ExecutionStatus.PROCESSING);
|
||||
|
||||
// 2. 创建模板实例并执行
|
||||
const template = await this.templateFactory.createTemplateByCode(execution.template.code);
|
||||
const taskId = await template.execute(execution.inputImageUrl);
|
||||
|
||||
// 3. 更新外部任务ID
|
||||
await this.executionRepository.update(execution.id, {
|
||||
taskId: taskId, // N8N或其他服务返回的任务ID
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('模板执行失败:', error);
|
||||
await this.updateExecutionStatus(
|
||||
execution.id,
|
||||
ExecutionStatus.FAILED,
|
||||
`模板执行失败: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新执行状态
|
||||
*/
|
||||
private async updateExecutionStatus(
|
||||
executionId: number,
|
||||
status: ExecutionStatus,
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
const updateData: Partial<TemplateExecutionEntity> = {
|
||||
status,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (errorMessage) {
|
||||
updateData.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
if (status === ExecutionStatus.FAILED || status === ExecutionStatus.AUDIT_FAILED) {
|
||||
updateData.completedAt = new Date();
|
||||
}
|
||||
|
||||
await this.executionRepository.update(executionId, updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询执行进度 - 增强版
|
||||
*/
|
||||
@Post(':executionId/status')
|
||||
@ApiOperation({ summary: '查询执行状态 (增强版)' })
|
||||
async getExecutionStatus(@Param('executionId') executionId: number) {
|
||||
try {
|
||||
const execution = await this.executionRepository.findOne({
|
||||
where: { id: executionId },
|
||||
relations: ['template'],
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
throw new HttpException('Execution not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// 如果是待审核状态,查询最新审核结果
|
||||
if (execution.status === ExecutionStatus.PENDING_AUDIT && execution.auditTaskId) {
|
||||
try {
|
||||
const auditResult = await this.unifiedContentService.queryAuditResult(
|
||||
execution.platform,
|
||||
execution.auditTaskId
|
||||
);
|
||||
|
||||
// 如果审核完成,更新状态
|
||||
if (auditResult.status === AuditStatus.COMPLETED) {
|
||||
await this.handleAuditComplete(execution.auditTaskId, auditResult);
|
||||
// 重新查询更新后的状态
|
||||
const updatedExecution = await this.executionRepository.findOne({
|
||||
where: { id: executionId },
|
||||
relations: ['template'],
|
||||
});
|
||||
if (updatedExecution) {
|
||||
execution.status = updatedExecution.status;
|
||||
execution.errorMessage = updatedExecution.errorMessage;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询审核状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
executionId: execution.id,
|
||||
templateId: execution.templateId,
|
||||
templateName: execution.template?.name,
|
||||
templateCode: execution.template?.code,
|
||||
status: execution.status,
|
||||
progress: execution.progress,
|
||||
auditTaskId: execution.auditTaskId,
|
||||
inputImageUrl: execution.inputImageUrl,
|
||||
outputUrl: execution.outputUrl,
|
||||
errorMessage: execution.errorMessage,
|
||||
startedAt: execution.startedAt,
|
||||
completedAt: execution.completedAt,
|
||||
|
||||
// 🎯 状态说明
|
||||
statusDescription: this.getStatusDescription(execution.status),
|
||||
},
|
||||
'查询成功'
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to get execution status',
|
||||
error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态描述
|
||||
*/
|
||||
private getStatusDescription(status: ExecutionStatus): string {
|
||||
switch (status) {
|
||||
case ExecutionStatus.PENDING_AUDIT:
|
||||
return '图片审核中,请稍候...';
|
||||
case ExecutionStatus.AUDIT_FAILED:
|
||||
return '图片审核未通过';
|
||||
case ExecutionStatus.PROCESSING:
|
||||
return 'AI生成中,请稍候...';
|
||||
case ExecutionStatus.COMPLETED:
|
||||
return '执行完成';
|
||||
case ExecutionStatus.FAILED:
|
||||
return '执行失败';
|
||||
default:
|
||||
return '未知状态';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户任务限制
|
||||
*/
|
||||
private async checkUserTaskLimit(userId: string): Promise<void> {
|
||||
// 复用原有逻辑
|
||||
const processingTasks = await this.executionRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
status: ExecutionStatus.PROCESSING,
|
||||
},
|
||||
order: {
|
||||
startedAt: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
if (processingTasks.length >= 3) {
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const recentTasks = processingTasks.filter(
|
||||
(task) => task.startedAt && task.startedAt.getTime() > fiveMinutesAgo.getTime(),
|
||||
);
|
||||
|
||||
if (recentTasks.length > 0) {
|
||||
throw new HttpException(
|
||||
`有${recentTasks.length}个任务正在进行中,请稍后再试`,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,14 +98,17 @@ export class TemplateController {
|
||||
const template = await this.templateFactory.createTemplate(templateId);
|
||||
const result = await template.execute(imageUrl);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
templateId,
|
||||
templateCode: template.code,
|
||||
templateName: template.name,
|
||||
inputUrl: imageUrl,
|
||||
outputUrl: result,
|
||||
creditCost: template.creditCost,
|
||||
}, '执行成功');
|
||||
},
|
||||
'执行成功',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Template execution failed',
|
||||
@@ -118,7 +121,8 @@ export class TemplateController {
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
@ApiOperation({
|
||||
summary: '通过代码执行模板',
|
||||
description: '根据模板代码执行AI生成任务,支持图片和视频生成。执行前会进行图片内容审核。',
|
||||
description:
|
||||
'根据模板代码执行AI生成任务,支持图片和视频生成。执行前会进行图片内容审核。',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'code',
|
||||
@@ -190,13 +194,14 @@ export class TemplateController {
|
||||
templateId: templateConfig.id,
|
||||
templateCode: code,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 2. 检查审核结果
|
||||
if (auditResult.conclusion !== AuditConclusion.PASS) {
|
||||
const failureReason = auditResult.details.length > 0
|
||||
? auditResult.details.map(d => d.description).join(', ')
|
||||
const failureReason =
|
||||
auditResult.details.length > 0
|
||||
? auditResult.details.map((d) => d.description).join(', ')
|
||||
: '包含不当内容';
|
||||
|
||||
throw new HttpException(
|
||||
@@ -235,7 +240,9 @@ export class TemplateController {
|
||||
console.error(error);
|
||||
throw new HttpException(
|
||||
error.message || 'Template execution failed',
|
||||
error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
error instanceof HttpException
|
||||
? error.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -325,14 +332,17 @@ export class TemplateController {
|
||||
imageUrls,
|
||||
);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
templateId,
|
||||
totalCount: imageUrls.length,
|
||||
results: imageUrls.map((inputUrl, index) => ({
|
||||
inputUrl,
|
||||
outputUrl: results[index],
|
||||
})),
|
||||
}, '批量执行成功');
|
||||
},
|
||||
'批量执行成功',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Batch execution failed',
|
||||
@@ -465,10 +475,13 @@ export class TemplateController {
|
||||
limit,
|
||||
);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
userTags,
|
||||
recommendedTemplates: templates,
|
||||
}, '模板推荐成功');
|
||||
},
|
||||
'模板推荐成功',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to recommend templates',
|
||||
@@ -496,7 +509,8 @@ export class TemplateController {
|
||||
try {
|
||||
const template = await this.templateFactory.createTemplate(templateId);
|
||||
|
||||
return ResponseUtil.success({
|
||||
return ResponseUtil.success(
|
||||
{
|
||||
id: templateId,
|
||||
code: template.code,
|
||||
name: template.name,
|
||||
@@ -506,7 +520,9 @@ export class TemplateController {
|
||||
tags: template.tags,
|
||||
inputExample: template.input,
|
||||
outputExample: template.output,
|
||||
}, '获取模板详情成功');
|
||||
},
|
||||
'获取模板详情成功',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Template not found',
|
||||
@@ -599,7 +615,8 @@ export class TemplateController {
|
||||
|
||||
const executions = await queryBuilder.getMany();
|
||||
|
||||
return ResponseUtil.success(executions.map((execution) => ({
|
||||
return ResponseUtil.success(
|
||||
executions.map((execution) => ({
|
||||
taskId: execution.id,
|
||||
templateId: execution.templateId,
|
||||
templateName: execution.template?.name,
|
||||
@@ -615,7 +632,9 @@ export class TemplateController {
|
||||
completedAt: execution.completedAt,
|
||||
executionDuration: execution.executionDuration,
|
||||
createdAt: execution.createdAt,
|
||||
})), '获取用户执行任务列表成功');
|
||||
})),
|
||||
'获取用户执行任务列表成功',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to fetch user executions',
|
||||
@@ -831,7 +850,13 @@ export class TemplateController {
|
||||
|
||||
const [templates, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return ResponseUtil.paginated(templates, total, page, limit, '获取模板列表成功');
|
||||
return ResponseUtil.paginated(
|
||||
templates,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
'获取模板列表成功',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to fetch templates',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MigrationInterface, QueryRunner, Table, Index } from 'typeorm';
|
||||
|
||||
export class CreateContentAuditLogTable1725510000000 implements MigrationInterface {
|
||||
export class CreateContentAuditLogTable1725510000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateContentAuditLogTable1725510000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
@@ -39,7 +41,7 @@ export class CreateContentAuditLogTable1725510000000 implements MigrationInterfa
|
||||
'feishu',
|
||||
'kuaishou',
|
||||
'h5',
|
||||
'rn'
|
||||
'rn',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
} from '../dto/platform-login.dto';
|
||||
import { PlatformAuthGuard } from '../guards/platform-auth.guard';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import { ResponseUtil, ApiResponse as ApiResponseType } from '../../utils/response.util';
|
||||
import {
|
||||
ResponseUtil,
|
||||
ApiResponse as ApiResponseType,
|
||||
} from '../../utils/response.util';
|
||||
|
||||
@ApiTags('用户管理')
|
||||
@Controller('users')
|
||||
@@ -56,7 +59,9 @@ export class UnifiedUserController {
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 401, description: '平台授权失败' })
|
||||
async login(@Body() loginDto: PlatformLoginDto): Promise<ApiResponseType<any>> {
|
||||
async login(
|
||||
@Body() loginDto: PlatformLoginDto,
|
||||
): Promise<ApiResponseType<any>> {
|
||||
const result = await this.unifiedUserService.login(
|
||||
loginDto.platform,
|
||||
loginDto,
|
||||
@@ -83,7 +88,9 @@ export class UnifiedUserController {
|
||||
},
|
||||
},
|
||||
})
|
||||
async register(@Body() registerDto: PlatformLoginDto): Promise<ApiResponseType<any>> {
|
||||
async register(
|
||||
@Body() registerDto: PlatformLoginDto,
|
||||
): Promise<ApiResponseType<any>> {
|
||||
const result = await this.unifiedUserService.register(
|
||||
registerDto.platform,
|
||||
registerDto,
|
||||
@@ -141,7 +148,10 @@ export class UnifiedUserController {
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权访问' })
|
||||
async updateProfile(@Request() req, @Body() updateDto: UserInfoUpdateDto): Promise<ApiResponseType<null>> {
|
||||
async updateProfile(
|
||||
@Request() req,
|
||||
@Body() updateDto: UserInfoUpdateDto,
|
||||
): Promise<ApiResponseType<null>> {
|
||||
await this.unifiedUserService.updateUserInfo(
|
||||
req.user.platform,
|
||||
req.user.userId,
|
||||
@@ -169,7 +179,10 @@ export class UnifiedUserController {
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权访问' })
|
||||
@ApiResponse({ status: 409, description: '平台账号已绑定其他用户' })
|
||||
async bindPlatform(@Request() req, @Body() bindDto: PlatformBindDto): Promise<ApiResponseType<null>> {
|
||||
async bindPlatform(
|
||||
@Request() req,
|
||||
@Body() bindDto: PlatformBindDto,
|
||||
): Promise<ApiResponseType<null>> {
|
||||
await this.unifiedUserService.bindPlatform(
|
||||
req.user.userId,
|
||||
bindDto.platform,
|
||||
|
||||
@@ -43,7 +43,7 @@ export class PlatformAuthGuard implements CanActivate {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log({token})
|
||||
console.log({ token });
|
||||
throw new UnauthorizedException('令牌验证失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export abstract class N8nImageGenerateTemplate extends ImageGenerateTemplate {
|
||||
})
|
||||
.then((res) => res.data)
|
||||
.then((res) => {
|
||||
if(res.task_id) return res.task_id
|
||||
if (res.task_id) return res.task_id;
|
||||
throw new Error(res.msg);
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export abstract class N8nVideoGenerateTemplate extends VideoGenerateTemplate {
|
||||
})
|
||||
.then((res) => res.data)
|
||||
.then((res) => {
|
||||
if(res.task_id) return res.task_id
|
||||
if (res.task_id) return res.task_id;
|
||||
throw new Error(res.msg);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user