Files
bw-mini-app-server/src/content-moderation/adapters/douyin-content.adapter.ts
imeepos 547bd1eba4 feat: 实现imageUrlToBase64图片转换功能和优化抖音SDK集成
- 添加imageUrlToBase64方法将图片URL转换为Base64格式
- 重构DouyinAuthClient移除第三方SDK依赖,使用原生HTTP请求
- 更新抖音内容审核API调用逻辑,使用image_data字段
- 优化错误处理和日志输出
- 修复依赖注入和模块配置问题
2025-09-08 15:26:32 +08:00

369 lines
11 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 { 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;
}
}
}