feat: 添加图片内容审核功能到模板执行流程
- 集成统一内容审核服务到模板控制器 - 在模板执行前进行图片内容审核 - 审核未通过时返回403错误和详细原因 - 添加内容审核模块和相关服务 - 创建内容审核日志表迁移文件
This commit is contained in:
278
src/content-moderation/adapters/douyin-content.adapter.ts
Normal file
278
src/content-moderation/adapters/douyin-content.adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user