feat: 添加图片内容审核功能到模板执行流程

- 集成统一内容审核服务到模板控制器
- 在模板执行前进行图片内容审核
- 审核未通过时返回403错误和详细原因
- 添加内容审核模块和相关服务
- 创建内容审核日志表迁移文件
This commit is contained in:
imeepos
2025-09-05 14:48:49 +08:00
parent 1e31514f11
commit 00bf807b31
19 changed files with 2393 additions and 6 deletions

View File

@@ -0,0 +1,278 @@
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;
}
}
}