解决抖音图片审核"无效图片URL"问题,并设计统一异步架构方案: ## 核心修复 - 增强 validateImageUrl 方法,支持文件扩展名 + Content-Type 双重验证 - 修复抖音审核失败问题:binary/octet-stream 响应头导致的验证失败 ## 架构设计 - 设计统一异步内容审核架构,抹平平台差异(同步/异步) - 创建 EnhancedBaseContentAdapter 统一接口 - 实现平台适配器模式,WeChat同步转异步,Douyin原生异步 - 添加回调驱动的模板执行流程 ## 数据库增强 - 新增 taskId 字段关联审核任务和模板执行 - 添加 PENDING_AUDIT 状态支持异步审核流程 - 创建 MySQL 优化的数据库迁移脚本 ## 文档完善 - 更新内容审核设计文档,包含详细序列图 - 创建 5 阶段升级文档,含 MySQL 专项优化 - 提供完整的向后兼容迁移方案 ## 测试改进 - 更新单元测试,修复 validateStatus 参数 - 增强错误处理和边界情况测试覆盖
307 lines
8.3 KiB
TypeScript
307 lines
8.3 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { BaseContentAdapter } from './base-content.adapter';
|
||
import { PlatformType } from '../../entities/platform-user.entity';
|
||
import {
|
||
ImageAuditRequest,
|
||
ContentAuditResult,
|
||
AuditStatus,
|
||
AuditConclusion,
|
||
RiskLevel,
|
||
AuditSuggestion,
|
||
} from '../interfaces/content-moderation.interface';
|
||
|
||
interface DouyinAuditResponse {
|
||
err_no: number;
|
||
err_tips: string;
|
||
log_id: string;
|
||
data: {
|
||
task_id: string;
|
||
status: number; // 0: 审核中, 1: 审核完成
|
||
conclusion: number; // 1: 合规, 2: 不合规, 3: 疑似, 4: 审核失败
|
||
confidence: number; // 置信度 0-100
|
||
details: Array<{
|
||
type: string; // 检测类型
|
||
label: string; // 具体标签
|
||
confidence: number; // 该项置信度
|
||
description: string; // 描述
|
||
}>;
|
||
};
|
||
}
|
||
|
||
@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',
|
||
),
|
||
};
|
||
|
||
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. 调用抖音审核API
|
||
const auditResult = await this.callDouyinAuditAPI({
|
||
...auditData,
|
||
taskId: auditLog.taskId,
|
||
});
|
||
|
||
// 4. 更新审核日志
|
||
await this.updateAuditLog(auditLog.taskId, auditResult);
|
||
|
||
return auditResult;
|
||
} catch (error) {
|
||
const auditError = this.handleAuditError(error, '抖音');
|
||
throw new BadRequestException(`抖音图片审核失败: ${auditError.message}`);
|
||
}
|
||
}
|
||
|
||
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: RiskLevel.MEDIUM,
|
||
suggestion: AuditSuggestion.HUMAN_REVIEW,
|
||
timestamp: new Date(),
|
||
});
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
async queryAuditResult(taskId: string): Promise<ContentAuditResult> {
|
||
try {
|
||
// 查询数据库中的审核结果
|
||
const auditLog = await this.auditLogRepository.findOne({
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
async handleAuditCallback(callbackData: any): Promise<void> {
|
||
try {
|
||
// 处理抖音审核回调
|
||
const { task_id, status, conclusion, confidence, details } = callbackData;
|
||
|
||
const auditResult: ContentAuditResult = {
|
||
taskId: task_id,
|
||
status: this.mapDouyinStatus(status),
|
||
conclusion: this.mapDouyinConclusion(conclusion),
|
||
confidence: confidence,
|
||
details: this.mapDouyinDetails(details),
|
||
riskLevel: this.calculateRiskLevel(conclusion, confidence),
|
||
suggestion: this.getSuggestion(conclusion),
|
||
platformData: callbackData,
|
||
timestamp: new Date(),
|
||
};
|
||
|
||
await this.updateAuditLog(task_id, auditResult);
|
||
} catch (error) {
|
||
console.error('处理抖音审核回调失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调用抖音审核API
|
||
*/
|
||
private async callDouyinAuditAPI(
|
||
auditData: ImageAuditRequest,
|
||
): Promise<ContentAuditResult> {
|
||
const requestData = {
|
||
app_id: this.douyinConfig.appId,
|
||
image_url: auditData.imageUrl,
|
||
task_id: auditData.taskId,
|
||
callback_url: this.configService.get('AUDIT_CALLBACK_URL') || '', // 异步回调URL
|
||
};
|
||
|
||
const response = await this.httpService.axiosRef.post(
|
||
this.douyinConfig.auditApiUrl,
|
||
requestData,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
// 添加认证头部
|
||
},
|
||
},
|
||
);
|
||
|
||
if (response.data.err_no !== 0) {
|
||
throw new Error(`抖音审核API错误: ${response.data.err_tips}`);
|
||
}
|
||
|
||
return this.parseDouyinResponse(response.data, auditData.taskId || '');
|
||
}
|
||
|
||
/**
|
||
* 查询抖音审核状态
|
||
*/
|
||
private async queryDouyinAuditStatus(
|
||
taskId: string,
|
||
): Promise<ContentAuditResult> {
|
||
const queryData = {
|
||
app_id: this.douyinConfig.appId,
|
||
task_id: taskId,
|
||
};
|
||
|
||
const response = await this.httpService.axiosRef.post(
|
||
`${this.douyinConfig.auditApiUrl}/query`,
|
||
queryData,
|
||
{
|
||
headers: { 'Content-Type': 'application/json' },
|
||
},
|
||
);
|
||
|
||
if (response.data.err_no !== 0) {
|
||
throw new Error(`查询抖音审核状态失败: ${response.data.err_tips}`);
|
||
}
|
||
|
||
return this.parseDouyinResponse(response.data, taskId);
|
||
}
|
||
|
||
/**
|
||
* 解析抖音响应
|
||
*/
|
||
private parseDouyinResponse(
|
||
responseData: DouyinAuditResponse,
|
||
taskId: string,
|
||
): ContentAuditResult {
|
||
const data = responseData.data;
|
||
|
||
return {
|
||
taskId: taskId,
|
||
status: this.mapDouyinStatus(data.status),
|
||
conclusion: this.mapDouyinConclusion(data.conclusion),
|
||
confidence: data.confidence,
|
||
details: this.mapDouyinDetails(data.details),
|
||
riskLevel: this.calculateRiskLevel(data.conclusion, data.confidence),
|
||
suggestion: this.getSuggestion(data.conclusion),
|
||
platformData: responseData,
|
||
timestamp: new Date(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 映射抖音状态到标准状态
|
||
*/
|
||
private mapDouyinStatus(status: number): AuditStatus {
|
||
switch (status) {
|
||
case 0:
|
||
return AuditStatus.PROCESSING;
|
||
case 1:
|
||
return AuditStatus.COMPLETED;
|
||
default:
|
||
return AuditStatus.FAILED;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 映射抖音结论到标准结论
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 映射抖音详细信息
|
||
*/
|
||
private mapDouyinDetails(details: any[]): any[] {
|
||
return (
|
||
details?.map((detail) => ({
|
||
type: detail.type,
|
||
label: detail.label,
|
||
confidence: detail.confidence,
|
||
description: detail.description,
|
||
})) || []
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 计算风险等级
|
||
*/
|
||
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;
|
||
if (conclusion === 3) return RiskLevel.MEDIUM;
|
||
return RiskLevel.MEDIUM;
|
||
}
|
||
|
||
/**
|
||
* 获取建议操作
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
}
|