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, ) { 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 { 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 { // 抖音批量审核实现 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 { 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 { 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 { 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 { 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 { // 确保有有效的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 { // 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; } } }