feat: 修复抖音图片审核失败并设计统一异步架构
解决抖音图片审核"无效图片URL"问题,并设计统一异步架构方案: ## 核心修复 - 增强 validateImageUrl 方法,支持文件扩展名 + Content-Type 双重验证 - 修复抖音审核失败问题:binary/octet-stream 响应头导致的验证失败 ## 架构设计 - 设计统一异步内容审核架构,抹平平台差异(同步/异步) - 创建 EnhancedBaseContentAdapter 统一接口 - 实现平台适配器模式,WeChat同步转异步,Douyin原生异步 - 添加回调驱动的模板执行流程 ## 数据库增强 - 新增 taskId 字段关联审核任务和模板执行 - 添加 PENDING_AUDIT 状态支持异步审核流程 - 创建 MySQL 优化的数据库迁移脚本 ## 文档完善 - 更新内容审核设计文档,包含详细序列图 - 创建 5 阶段升级文档,含 MySQL 专项优化 - 提供完整的向后兼容迁移方案 ## 测试改进 - 更新单元测试,修复 validateStatus 参数 - 增强错误处理和边界情况测试覆盖
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user