feat: 修复抖音图片审核失败并设计统一异步架构

解决抖音图片审核"无效图片URL"问题,并设计统一异步架构方案:

## 核心修复
- 增强 validateImageUrl 方法,支持文件扩展名 + Content-Type 双重验证
- 修复抖音审核失败问题:binary/octet-stream 响应头导致的验证失败

## 架构设计
- 设计统一异步内容审核架构,抹平平台差异(同步/异步)
- 创建 EnhancedBaseContentAdapter 统一接口
- 实现平台适配器模式,WeChat同步转异步,Douyin原生异步
- 添加回调驱动的模板执行流程

## 数据库增强
- 新增 taskId 字段关联审核任务和模板执行
- 添加 PENDING_AUDIT 状态支持异步审核流程
- 创建 MySQL 优化的数据库迁移脚本

## 文档完善
- 更新内容审核设计文档,包含详细序列图
- 创建 5 阶段升级文档,含 MySQL 专项优化
- 提供完整的向后兼容迁移方案

## 测试改进
- 更新单元测试,修复 validateStatus 参数
- 增强错误处理和边界情况测试覆盖
This commit is contained in:
imeepos
2025-09-05 16:08:45 +08:00
parent 082775094e
commit 4cb6b63948
31 changed files with 2795 additions and 470 deletions

View File

@@ -1,13 +1,13 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { BaseContentAdapter } from './base-content.adapter';
import { PlatformType } from '../../entities/platform-user.entity';
import {
ImageAuditRequest,
ContentAuditResult,
AuditStatus,
import {
ImageAuditRequest,
ContentAuditResult,
AuditStatus,
AuditConclusion,
RiskLevel,
AuditSuggestion
AuditSuggestion,
} from '../interfaces/content-moderation.interface';
interface DouyinAuditResponse {
@@ -16,14 +16,14 @@ interface DouyinAuditResponse {
log_id: string;
data: {
task_id: string;
status: number; // 0: 审核中, 1: 审核完成
conclusion: number; // 1: 合规, 2: 不合规, 3: 疑似, 4: 审核失败
confidence: number; // 置信度 0-100
status: number; // 0: 审核中, 1: 审核完成
conclusion: number; // 1: 合规, 2: 不合规, 3: 疑似, 4: 审核失败
confidence: number; // 置信度 0-100
details: Array<{
type: string; // 检测类型
label: string; // 具体标签
confidence: number; // 该项置信度
description: string; // 描述
type: string; // 检测类型
label: string; // 具体标签
confidence: number; // 该项置信度
description: string; // 描述
}>;
};
}
@@ -31,18 +31,21 @@ interface DouyinAuditResponse {
@Injectable()
export class DouyinContentAdapter extends BaseContentAdapter {
platform = PlatformType.BYTEDANCE;
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> {
try {
// 1. 创建审核日志
const auditLog = await this.createAuditLog(auditData);
// 2. 验证图片URL
const isValidImage = await this.validateImageUrl(auditData.imageUrl);
if (!isValidImage) {
@@ -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,10 +68,12 @@ export class DouyinContentAdapter extends BaseContentAdapter {
}
}
async auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
async auditImageBatch(
auditDataList: ImageAuditRequest[],
): Promise<ContentAuditResult[]> {
// 抖音批量审核实现
const results: ContentAuditResult[] = [];
for (const auditData of auditDataList) {
try {
const result = await this.auditImage(auditData);
@@ -87,7 +92,7 @@ export class DouyinContentAdapter extends BaseContentAdapter {
});
}
}
return results;
}
@@ -95,26 +100,26 @@ export class DouyinContentAdapter extends BaseContentAdapter {
try {
// 查询数据库中的审核结果
const auditLog = await this.auditLogRepository.findOne({
where: { taskId, platform: this.platform }
where: { taskId, platform: this.platform },
});
if (!auditLog) {
throw new Error('审核任务不存在');
}
// 如果审核完成,直接返回结果
if (auditLog.status === AuditStatus.COMPLETED) {
return auditLog.auditResult;
}
// 如果审核中调用平台API查询最新状态
const platformResult = await this.queryDouyinAuditStatus(taskId);
// 更新数据库
if (platformResult.status === AuditStatus.COMPLETED) {
await this.updateAuditLog(taskId, platformResult);
}
return platformResult;
} catch (error) {
throw new BadRequestException(`查询审核结果失败: ${error.message}`);
@@ -125,7 +130,7 @@ export class DouyinContentAdapter extends BaseContentAdapter {
try {
// 处理抖音审核回调
const { task_id, status, conclusion, confidence, details } = callbackData;
const auditResult: ContentAuditResult = {
taskId: task_id,
status: this.mapDouyinStatus(status),
@@ -137,7 +142,7 @@ export class DouyinContentAdapter extends BaseContentAdapter {
platformData: callbackData,
timestamp: new Date(),
};
await this.updateAuditLog(task_id, auditResult);
} catch (error) {
console.error('处理抖音审核回调失败:', error);
@@ -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,9 +209,12 @@ export class DouyinContentAdapter extends BaseContentAdapter {
/**
* 解析抖音响应
*/
private parseDouyinResponse(responseData: DouyinAuditResponse, taskId: string): ContentAuditResult {
private parseDouyinResponse(
responseData: DouyinAuditResponse,
taskId: string,
): ContentAuditResult {
const data = responseData.data;
return {
taskId: taskId,
status: this.mapDouyinStatus(data.status),
@@ -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 4:
default: return AuditConclusion.UNCERTAIN;
case 1:
return AuditConclusion.PASS;
case 2:
return AuditConclusion.REJECT;
case 3:
return AuditConclusion.REVIEW;
case 4:
default:
return AuditConclusion.UNCERTAIN;
}
}
@@ -244,18 +263,23 @@ export class DouyinContentAdapter extends BaseContentAdapter {
* 映射抖音详细信息
*/
private mapDouyinDetails(details: any[]): any[] {
return details?.map(detail => ({
type: detail.type,
label: detail.label,
confidence: detail.confidence,
description: detail.description,
})) || [];
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 4:
default: 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;
}
}
}
}