refactor: 重构抖音内容审核适配器使用SDK v3并集成OAuth认证

主要变更:
- 集成抖音OpenAPI SDK v3替代原有的HTTP请求方式
- 实现OAuth客户端令牌自动获取和刷新机制
- 统一环境变量配置,使用BYTEDANCE_APP_ID/APP_SECRET同时支持小程序和开放平台API
- 添加智能token缓存和过期检测
- 更新响应解析逻辑适配SDK v3格式
- 添加测试接口验证token获取功能

技术改进:
- 自动token管理,无需手动维护访问令牌
- 提前5分钟刷新token避免过期
- 完善的错误处理和类型安全
- 移除重复配置,简化环境变量管理
This commit is contained in:
imeepos
2025-09-08 12:43:05 +08:00
parent 2bac851e6d
commit 6cd2e75d15
5 changed files with 243 additions and 76 deletions

View File

@@ -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

View File

@@ -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",

68
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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();

View File

@@ -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<ContentAuditLogEntity>,
) {
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<ContentAuditResult> {
try {
// 1. 创建审核日志
@@ -150,79 +167,141 @@ export class DouyinContentAdapter extends BaseContentAdapter {
}
/**
* 调用抖音审核API
* 获取访问令牌
*/
private async getAccessToken(): Promise<string> {
// 检查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<string> {
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<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
};
// 确保有有效的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<ContentAuditResult> {
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 ? '检测到违规内容' : '内容安全',
})) || []
);
}
/**
* 计算风险等级
*/