diff --git a/.env.example b/.env.example index 733b80d..7117198 100644 --- a/.env.example +++ b/.env.example @@ -21,17 +21,19 @@ JWT_SECRET=a6e4d948295ec0ae53921d67d4ce97cfff0565073e370d6094e43705cd48914b68d38 WECHAT_APP_ID=wxb51f0b0c3aad7cdf WECHAT_APP_SECRET=your_wechat_app_secret -# 抖音小程序配置 +# 抖音小程序配置(同时用于V3 SDK) BYTEDANCE_APP_ID=ttbfd9c96420ec8f8201 BYTEDANCE_APP_SECRET=04a026867fbd1ba52174c7c21d94cbc7361ec378 +BYTEDANCE_GRANT_TYPE=client_credential # 内容审核配置 # 图片审核回调地址(重要:抖音审核完成后会调用此接口) AUDIT_CALLBACK_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com/api/v1/content-moderation/bytedance/callback TEST_SERVER_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com PROD_SERVER_URL=https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com -# 抖音内容审核API地址 -BYTEDANCE_AUDIT_API_URL=https://developer.toutiao.com/api/apps/v2/content/audit/image +# 抖音内容审核API地址(已更新为V3版本) +# 旧版本API(已弃用): https://developer.toutiao.com/api/apps/v2/content/audit/image +# 新版本V3 API通过SDK内置端点调用 # 微信内容审核API地址 WECHAT_AUDIT_API_URL=https://api.weixin.qq.com/wxa/img_sec_check \ No newline at end of file diff --git a/package.json b/package.json index dbe8ded..6365d76 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", + "@open-dy/open_api_credential": "^1.0.0", + "@open-dy/open_api_sdk": "^1.1.3", "axios": "^1.11.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 199c3f9..5283bdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@nestjs/typeorm': specifier: ^11.0.0 version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.26(mysql2@3.14.4)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2))) + '@open-dy/open_api_credential': + specifier: ^1.0.0 + version: 1.0.0 + '@open-dy/open_api_sdk': + specifier: ^1.1.3 + version: 1.1.3 axios: specifier: ^1.11.0 version: 1.11.0 @@ -126,6 +132,9 @@ importers: packages: + '@alicloud/tea-typescript@1.8.0': + resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -807,6 +816,15 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@open-dy/open_api_credential@1.0.0': + resolution: {integrity: sha512-MmLrJL2QFYHV3RNJ2/hQEXcjzBCa6mtklW+AJXG+xS/x8cJ7/AgW3wQjvNwJ0oMRyxch1nQ10h9EnOGr1kew0g==} + + '@open-dy/open_api_sdk@1.1.3': + resolution: {integrity: sha512-N04K4zkfa5tadbo+iz0ZCdHvNq+9sdzwWZzP4OX0rEmba0VXVlYstzjFpvYsTp+nQcJqab2DvyaXhShJfO1/rg==} + + '@open-dy/open_api_util@1.0.1': + resolution: {integrity: sha512-vgUTsyNqB0mtn0LclEr7J2E0eoXTHym1Xh4hO5gXXGYS4MoZGpu9IbYlkR4LMVUSOmJs5ZhRxmNqvEt4D/5fLg==} + '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} @@ -918,6 +936,12 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@20.19.13': + resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==} + '@types/node@22.18.0': resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} @@ -2013,6 +2037,9 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + httpx@2.3.3: + resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3370,6 +3397,13 @@ packages: snapshots: + '@alicloud/tea-typescript@1.8.0': + dependencies: + '@types/node': 12.20.55 + httpx: 2.3.3 + transitivePeerDependencies: + - supports-color + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -4223,6 +4257,27 @@ snapshots: dependencies: consola: 3.4.2 + '@open-dy/open_api_credential@1.0.0': + dependencies: + axios: 1.11.0 + transitivePeerDependencies: + - debug + + '@open-dy/open_api_sdk@1.1.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@open-dy/open_api_credential': 1.0.0 + '@open-dy/open_api_util': 1.0.1 + transitivePeerDependencies: + - debug + - supports-color + + '@open-dy/open_api_util@1.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + '@paralleldrive/cuid2@2.2.2': dependencies: '@noble/hashes': 1.8.0 @@ -4353,6 +4408,12 @@ snapshots: '@types/mime@1.3.5': {} + '@types/node@12.20.55': {} + + '@types/node@20.19.13': + dependencies: + undici-types: 6.21.0 + '@types/node@22.18.0': dependencies: undici-types: 6.21.0 @@ -5516,6 +5577,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + httpx@2.3.3: + dependencies: + '@types/node': 20.19.13 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.6.3: diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index aa841dc..12310a4 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -50,11 +50,13 @@ export function setupSwagger(app: INestApplication): void { .addTag('扩展服务', '预留的扩展功能接口') .addServer(`http://localhost:${process.env.PORT || 3003}`, '开发环境') .addServer( - process.env.TEST_SERVER_URL || 'https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com', + process.env.TEST_SERVER_URL || + 'https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com', '测试环境', ) .addServer( - process.env.PROD_SERVER_URL || 'https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com', + process.env.PROD_SERVER_URL || + 'https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com', '生产环境', ) .build(); diff --git a/src/content-moderation/adapters/douyin-content.adapter.ts b/src/content-moderation/adapters/douyin-content.adapter.ts index ddd7e4d..b2d2538 100644 --- a/src/content-moderation/adapters/douyin-content.adapter.ts +++ b/src/content-moderation/adapters/douyin-content.adapter.ts @@ -1,5 +1,16 @@ 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 Client, { + CensorImageRequest, + CensorImageResponse, + OauthClientTokenRequest, +} from '@open-dy/open_api_sdk'; +import CredentialClient from '@open-dy/open_api_credential'; import { BaseContentAdapter } from './base-content.adapter'; +import { ContentAuditLogEntity } from '../entities/content-audit-log.entity'; import { PlatformType } from '../../entities/platform-user.entity'; import { ImageAuditRequest, @@ -10,37 +21,43 @@ import { 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', - ), + private douyinConfig: { + clientKey: string; + clientSecret: string; + appId: string; + grantType: string; }; + private douyinClient: Client; + private accessToken: string | null = null; + private tokenExpiresAt: Date | null = null; + + constructor( + protected readonly httpService: HttpService, + protected readonly configService: ConfigService, + @InjectRepository(ContentAuditLogEntity) + protected readonly auditLogRepository: Repository, + ) { + super(httpService, configService, auditLogRepository); + + // 抖音开放平台配置: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', + }; + + this.douyinClient = new Client({ + clientKey: this.douyinConfig.clientKey, + clientSecret: this.douyinConfig.clientSecret, + }); + } + async auditImage(auditData: ImageAuditRequest): Promise { try { // 1. 创建审核日志 @@ -150,79 +167,141 @@ export class DouyinContentAdapter extends BaseContentAdapter { } /** - * 调用抖音审核API + * 获取访问令牌 + */ + private async getAccessToken(): Promise { + // 检查token是否有效 + if (this.accessToken && this.tokenExpiresAt && new Date() < this.tokenExpiresAt) { + return this.accessToken; + } + + // 获取新的token + const params = new OauthClientTokenRequest({ + clientKey: this.douyinConfig.clientKey, + clientSecret: this.douyinConfig.clientSecret, + grantType: this.douyinConfig.grantType, + }); + + const response = await this.douyinClient.oauthClientToken(params); + + if (response.errNo && response.errNo !== 0) { + throw new Error(`获取访问令牌失败: ${response.errMsg || '未知错误'}`); + } + + // 存储token和过期时间 + this.accessToken = response.accessToken || ''; + if (response.expiresIn) { + // 提前5分钟刷新token + const expiresIn = parseInt(response.expiresIn.toString()) - 300; + this.tokenExpiresAt = new Date(Date.now() + expiresIn * 1000); + } + + if (!this.accessToken) { + throw new Error('获取访问令牌失败: 返回的accessToken为空'); + } + + return this.accessToken; + } + + /** + * 检查并刷新token + */ + private async ensureValidToken(): Promise { + if (!this.accessToken || !this.tokenExpiresAt || new Date() >= this.tokenExpiresAt) { + return this.getAccessToken(); + } + return this.accessToken; + } + + /** + * 测试token获取功能 - 公共方法用于测试 + */ + async testTokenAcquisition(): Promise<{ + success: boolean; + accessToken?: string; + expiresAt?: Date; + error?: string; + }> { + try { + const token = await this.getAccessToken(); + return { + success: true, + accessToken: token.substring(0, 10) + '...', // 只显示前10位 + expiresAt: this.tokenExpiresAt || undefined, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误', + }; + } + } + + /** + * 调用抖音审核API (使用SDK v3) */ private async callDouyinAuditAPI( auditData: ImageAuditRequest, ): Promise { - const requestData = { - app_id: this.douyinConfig.appId, - image_url: auditData.imageUrl, - task_id: auditData.taskId, - callback_url: this.configService.get('AUDIT_CALLBACK_URL') || '', // 异步回调URL - }; + // 确保有有效的access token + const accessToken = await this.ensureValidToken(); + + const params = new CensorImageRequest({ + accessToken: accessToken, + appId: this.douyinConfig.appId, + image: auditData.imageUrl, + imageData: auditData.imageBase64, // base64编码的图片数据(可选) + }); - const response = await this.httpService.axiosRef.post( - this.douyinConfig.auditApiUrl, - requestData, - { - headers: { - 'Content-Type': 'application/json', - // 添加认证头部 - }, - }, - ); + const response = await this.douyinClient.censorImage(params); - if (response.data.err_no !== 0) { - throw new Error(`抖音审核API错误: ${response.data.err_tips}`); + if (response.errNo && response.errNo !== 0) { + throw new Error(`抖音审核API错误: ${response.errMsg || '未知错误'}`); } - return this.parseDouyinResponse(response.data, auditData.taskId || ''); + return this.parseDouyinSDKResponse(response, auditData.taskId || ''); } /** - * 查询抖音审核状态 + * 查询抖音审核状态 (使用SDK v3) */ private async queryDouyinAuditStatus( taskId: string, ): Promise { - const queryData = { - app_id: this.douyinConfig.appId, - task_id: taskId, - }; + // SDK v3暂不提供查询接口,直接返回数据库中的结果 + // 实际项目中可以使用SDK的其他方法或直接调用API + const auditLog = await this.auditLogRepository.findOne({ + where: { taskId, platform: this.platform }, + }); - 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}`); + if (!auditLog) { + throw new Error('审核任务不存在'); } - return this.parseDouyinResponse(response.data, taskId); + return auditLog.auditResult; } /** - * 解析抖音响应 + * 解析抖音SDK响应 */ - private parseDouyinResponse( - responseData: DouyinAuditResponse, + private parseDouyinSDKResponse( + responseData: CensorImageResponse, taskId: string, ): ContentAuditResult { - const data = responseData.data; + // SDK v3的响应格式不同,需要根据实际返回结果进行适配 + const isHit = + responseData.predicts?.some((predict) => predict.hit) || false; + const conclusion = isHit ? 2 : 1; // 2: 不合规, 1: 合规 + const confidence = 80; // SDK v3暂不提供具体置信度,使用默认值 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), + status: AuditStatus.COMPLETED, // SDK v3直接返回结果 + conclusion: this.mapDouyinConclusion(conclusion), + confidence: confidence, + details: this.mapDouyinSDKDetails(responseData.predicts || []), + riskLevel: this.calculateRiskLevel(conclusion, confidence), + suggestion: this.getSuggestion(conclusion), platformData: responseData, timestamp: new Date(), }; @@ -273,6 +352,20 @@ export class DouyinContentAdapter extends BaseContentAdapter { ); } + /** + * 映射抖音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 ? '检测到违规内容' : '内容安全', + })) || [] + ); + } + /** * 计算风险等级 */