Files
bw-mini-app-server/src/content-moderation/adapters/douyin-content.adapter.ts
imeepos 4cb6b63948 feat: 修复抖音图片审核失败并设计统一异步架构
解决抖音图片审核"无效图片URL"问题,并设计统一异步架构方案:

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

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

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

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

## 测试改进
- 更新单元测试,修复 validateStatus 参数
- 增强错误处理和边界情况测试覆盖
2025-09-05 16:08:45 +08:00

307 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}