- 添加imageUrlToBase64方法将图片URL转换为Base64格式 - 重构DouyinAuthClient移除第三方SDK依赖,使用原生HTTP请求 - 更新抖音内容审核API调用逻辑,使用image_data字段 - 优化错误处理和日志输出 - 修复依赖注入和模块配置问题
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { HttpService } from '@nestjs/axios';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Repository } from 'typeorm';
|
||
import { firstValueFrom } from 'rxjs';
|
||
import { BaseContentAdapter } from './base-content.adapter';
|
||
import { ContentAuditLogEntity } from '../entities/content-audit-log.entity';
|
||
import { PlatformType } from '../../entities/platform-user.entity';
|
||
import {
|
||
ImageAuditRequest,
|
||
ContentAuditResult,
|
||
AuditStatus,
|
||
AuditConclusion,
|
||
RiskLevel,
|
||
AuditSuggestion,
|
||
} from '../interfaces/content-moderation.interface';
|
||
import { DouyinAuthClient } from './DouYinClient';
|
||
@Injectable()
|
||
export class DouyinContentAdapter extends BaseContentAdapter {
|
||
platform = PlatformType.BYTEDANCE;
|
||
protected readonly douyinAuthClient: DouyinAuthClient;
|
||
private douyinConfig: {
|
||
clientKey: string;
|
||
clientSecret: string;
|
||
appId: string;
|
||
grantType: string;
|
||
};
|
||
|
||
constructor(
|
||
protected readonly httpService: HttpService,
|
||
protected readonly configService: ConfigService,
|
||
|
||
@InjectRepository(ContentAuditLogEntity)
|
||
protected readonly auditLogRepository: Repository<ContentAuditLogEntity>,
|
||
) {
|
||
super(httpService, configService, auditLogRepository);
|
||
this.douyinAuthClient = new DouyinAuthClient(
|
||
this.httpService,
|
||
this.configService,
|
||
);
|
||
|
||
// 抖音开放平台配置:clientKey/clientSecret 实际使用的是 APP_ID/APP_SECRET
|
||
this.douyinConfig = {
|
||
clientKey: this.configService.get('BYTEDANCE_APP_ID') || '',
|
||
clientSecret: this.configService.get('BYTEDANCE_APP_SECRET') || '',
|
||
appId: this.configService.get('BYTEDANCE_APP_ID') || '',
|
||
grantType:
|
||
this.configService.get('BYTEDANCE_GRANT_TYPE') || 'client_credential',
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将图片URL转换为Base64格式
|
||
*/
|
||
private async imageUrlToBase64(imageUrl: string): Promise<string> {
|
||
try {
|
||
const response = await firstValueFrom(
|
||
this.httpService.get(imageUrl, { responseType: 'arraybuffer' }),
|
||
);
|
||
|
||
const buffer = Buffer.from(response.data);
|
||
const base64String = buffer.toString('base64');
|
||
|
||
return base64String;
|
||
} catch (error) {
|
||
throw new Error(`转换图片为Base64失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取访问令牌
|
||
*/
|
||
private async getAccessToken(): Promise<string> {
|
||
if (!this.douyinAuthClient) {
|
||
throw new Error('DouyinAuthClient 未正确注入');
|
||
}
|
||
|
||
if (typeof this.douyinAuthClient.getAccessToken !== 'function') {
|
||
throw new Error(
|
||
`DouyinAuthClient.getAccessToken 不是函数,类型: ${typeof this.douyinAuthClient.getAccessToken}`,
|
||
);
|
||
}
|
||
|
||
return this.douyinAuthClient
|
||
.getAccessToken()
|
||
.then((res) => res.accessToken);
|
||
}
|
||
|
||
/**
|
||
* 调用抖音审核API (使用SDK v3)
|
||
* {
|
||
"app_id": "ttxxxxxxxxxxxxxxxx",
|
||
"access_token": "0d495e15563015e3f599c742384f546cac4ce63911464106af8094a0581bae7386dcff77b1b9b6fc4c16b69c9048ba2a2846c7ae8d8f07aa8b84a52bcb4d560a5b8724d99f8816600b5xxxxxxxxxx",
|
||
"image_data": "MGQ0OTVlMTU1NjMwMTVlM2Y1OTljNzQyMzg0ZjU0NmNhYzRjZTYzOTExNDY0MTA2YWY4MDk0YTA1ODFiYWU3Mzg2ZGNmZjc3YjFiOWI2ZmM0YzE2YjY5YzkwNDhiYTJhMjg0NmM3YWU4ZDhmMDdhYThiODRhNTJiY2I0ZDU2MGE1Yjg3MjRkOTlmODgxNjYwMGI1eHh4eHh4eHh4eA=="
|
||
}
|
||
*/
|
||
private async callDouyinAuditAPI(
|
||
auditData: ImageAuditRequest,
|
||
): Promise<ContentAuditResult> {
|
||
// 确保有有效的access token
|
||
const accessToken = await this.getAccessToken();
|
||
const url = `https://open.douyin.com/api/apps/v1/censor/image/`;
|
||
// 将图片转化为 base64格式的图片
|
||
const imageData = await this.imageUrlToBase64(auditData.imageUrl);
|
||
const response = await firstValueFrom(
|
||
this.httpService.post(
|
||
url,
|
||
{
|
||
app_id: this.douyinConfig.appId,
|
||
image_data: imageData,
|
||
[`access-token`]: accessToken,
|
||
},
|
||
{
|
||
headers: {
|
||
[`access-token`]: accessToken,
|
||
[`content-type`]: `application/json`,
|
||
},
|
||
},
|
||
),
|
||
);
|
||
const data = response.data;
|
||
return this.parseDouyinSDKResponse(response.data, auditData.taskId || '');
|
||
}
|
||
|
||
/**
|
||
* 查询抖音审核状态 (使用SDK v3)
|
||
*/
|
||
private async queryDouyinAuditStatus(
|
||
taskId: string,
|
||
): Promise<ContentAuditResult> {
|
||
// SDK v3暂不提供查询接口,直接返回数据库中的结果
|
||
// 实际项目中可以使用SDK的其他方法或直接调用API
|
||
const auditLog = await this.auditLogRepository.findOne({
|
||
where: { taskId, platform: this.platform },
|
||
});
|
||
|
||
if (!auditLog) {
|
||
throw new Error('审核任务不存在');
|
||
}
|
||
|
||
return auditLog.auditResult;
|
||
}
|
||
|
||
/**
|
||
* 解析抖音SDK响应
|
||
*/
|
||
private parseDouyinSDKResponse(
|
||
responseData: any,
|
||
taskId: string,
|
||
): ContentAuditResult {
|
||
// SDK v3的响应格式不同,需要根据实际返回结果进行适配
|
||
const isHit =
|
||
responseData.predicts?.some((predict) => predict.hit) || false;
|
||
console.log(responseData.predicts)
|
||
const conclusion = isHit ? 2 : 1; // 2: 不合规, 1: 合规
|
||
|
||
return {
|
||
taskId: taskId,
|
||
status: AuditStatus.COMPLETED, // SDK v3直接返回结果
|
||
conclusion: this.mapDouyinConclusion(conclusion),
|
||
confidence: 100,
|
||
details: this.mapDouyinSDKDetails(responseData.predicts || []),
|
||
riskLevel: this.calculateRiskLevel(conclusion, 100),
|
||
suggestion: this.getSuggestion(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,
|
||
})) || []
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 映射抖音SDK v3详细信息
|
||
*/
|
||
private mapDouyinSDKDetails(predicts: any[]): any[] {
|
||
return (
|
||
predicts?.map((predict) => ({
|
||
type: predict.modelName || 'unknown',
|
||
label: predict.hit ? 'violation' : 'safe',
|
||
confidence: predict.hit ? 80 : 20, // SDK v3暂不提供具体置信度
|
||
description: predict.hit ? '检测到违规内容' : '内容安全',
|
||
})) || []
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 计算风险等级
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
}
|