feat: 实现imageUrlToBase64图片转换功能和优化抖音SDK集成

- 添加imageUrlToBase64方法将图片URL转换为Base64格式
- 重构DouyinAuthClient移除第三方SDK依赖,使用原生HTTP请求
- 更新抖音内容审核API调用逻辑,使用image_data字段
- 优化错误处理和日志输出
- 修复依赖注入和模块配置问题
This commit is contained in:
imeepos
2025-09-08 15:26:32 +08:00
parent 8eeedb444f
commit 547bd1eba4
7 changed files with 169 additions and 78 deletions

View File

@@ -38,8 +38,6 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.0", "@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@open-dy/open_api_credential": "^1.0.0",
"@open-dy/open_api_sdk": "1.1.2",
"axios": "^1.11.0", "axios": "^1.11.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",

View File

@@ -1,46 +1,94 @@
import { Injectable } from "@nestjs/common"; import { HttpService } from '@nestjs/axios';
import { ConfigService } from "@nestjs/config"; import { Injectable } from '@nestjs/common';
import Client from '@open-dy/open_api_credential'; import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
/** /**
名称 描述
HTTP URL
正式地址https://developer.toutiao.com/api/apps/v2/token
HTTP Method POST
名称 描述 content-type: application/json
HTTP URL https://open.douyin.com/oauth/access_token/
HTTP Method POST
content-type: application/x-www-form-urlencoded"
body: body:
- client_key - appid
- client_secret - grant_type: client_credential
- secret
*/ */
interface TokenResponse {
err_no: number;
err_tips: string;
data: {
access_token: string;
expires_in: number;
};
}
interface AccessTokenResult {
accessToken: string;
expiresIn: number;
}
@Injectable() @Injectable()
export class DouyinAuthClient { export class DouyinAuthClient {
private readonly client: Client; private appid: string;
constructor(private config: ConfigService) { private secret: string;
console.log('DouyinAuthClient constructor - creating client with:', { private grantType: string = 'client_credential';
clientKey: this.config.get('BYTEDANCE_APP_ID') || '', private accessToken: string | null = null;
clientSecret: this.config.get('BYTEDANCE_APP_SECRET') ? '***' : 'empty' private tokenExpires: number = 0;
});
this.client = new Client({ constructor(
clientKey: this.config.get('BYTEDANCE_APP_ID') || '', private http: HttpService,
clientSecret: this.config.get('BYTEDANCE_APP_SECRET') || '', private config: ConfigService,
}); ) {
console.log('DouyinAuthClient constructor - client created:', !!this.client); this.appid = this.config.get('BYTEDANCE_APP_ID') || '';
console.log('DouyinAuthClient constructor - client.getAccessToken type:', typeof this.client.getAccessToken); this.secret = this.config.get('BYTEDANCE_APP_SECRET') || '';
}
async getAccessToken(): Promise<AccessTokenResult> {
// 检查是否有有效的缓存token
if (this.accessToken && Date.now() < this.tokenExpires) {
return {
accessToken: this.accessToken,
expiresIn: this.tokenExpires,
};
} }
async getAccessToken() { try {
console.log('DouyinAuthClient.getAccessToken called'); const response = await firstValueFrom(
console.log('client:', !!this.client); this.http.post<TokenResponse>(
console.log('client.getAccessToken type:', typeof this.client.getAccessToken); 'https://open.douyin.com/oauth/client_token',
try { {
const result = await this.client.getAccessToken(); client_key: this.appid,
console.log('getAccessToken result:', result); grant_type: this.grantType,
return result; client_secret: this.secret,
} catch (error) { },
console.error('getAccessToken error:', error); {
throw error; headers: {
} 'Content-Type': 'application/json',
},
},
),
);
if (response.data.data) {
this.accessToken = response.data.data.access_token;
this.tokenExpires = Date.now() + response.data.data.expires_in * 1000;
return {
accessToken: this.accessToken,
expiresIn: this.tokenExpires,
};
} else {
throw new Error(
`获取access token失败: ${response.data.err_tips} (错误码: ${response.data.err_no})`,
);
}
} catch (error) {
console.error('getAccessToken error:', error);
throw new Error(
`DouyinAuthClient获取访问令牌失败: ${error instanceof Error ? error.message : String(error)}`,
);
} }
} }
}

View File

@@ -19,7 +19,7 @@ import { DouyinAuthClient } from './DouYinClient';
@Injectable() @Injectable()
export class DouyinContentAdapter extends BaseContentAdapter { export class DouyinContentAdapter extends BaseContentAdapter {
platform = PlatformType.BYTEDANCE; platform = PlatformType.BYTEDANCE;
protected readonly douyinAuthClient: DouyinAuthClient;
private douyinConfig: { private douyinConfig: {
clientKey: string; clientKey: string;
clientSecret: string; clientSecret: string;
@@ -30,25 +30,23 @@ export class DouyinContentAdapter extends BaseContentAdapter {
constructor( constructor(
protected readonly httpService: HttpService, protected readonly httpService: HttpService,
protected readonly configService: ConfigService, protected readonly configService: ConfigService,
protected readonly douyinAuthClient: DouyinAuthClient,
@InjectRepository(ContentAuditLogEntity) @InjectRepository(ContentAuditLogEntity)
protected readonly auditLogRepository: Repository<ContentAuditLogEntity>, protected readonly auditLogRepository: Repository<ContentAuditLogEntity>,
) { ) {
super(httpService, configService, auditLogRepository); super(httpService, configService, auditLogRepository);
this.douyinAuthClient = new DouyinAuthClient(
// 调试:检查 douyinAuthClient 是否正确注入 this.httpService,
console.log('DouyinContentAdapter constructor - douyinAuthClient:', this.douyinAuthClient); this.configService,
console.log('douyinAuthClient type:', typeof this.douyinAuthClient); );
if (this.douyinAuthClient) {
console.log('douyinAuthClient.getAccessToken type:', typeof this.douyinAuthClient.getAccessToken);
}
// 抖音开放平台配置clientKey/clientSecret 实际使用的是 APP_ID/APP_SECRET // 抖音开放平台配置clientKey/clientSecret 实际使用的是 APP_ID/APP_SECRET
this.douyinConfig = { this.douyinConfig = {
clientKey: this.configService.get('BYTEDANCE_APP_ID') || '', clientKey: this.configService.get('BYTEDANCE_APP_ID') || '',
clientSecret: this.configService.get('BYTEDANCE_APP_SECRET') || '', clientSecret: this.configService.get('BYTEDANCE_APP_SECRET') || '',
appId: this.configService.get('BYTEDANCE_APP_ID') || '', appId: this.configService.get('BYTEDANCE_APP_ID') || '',
grantType: this.configService.get('BYTEDANCE_GRANT_TYPE') || 'client_credential', grantType:
this.configService.get('BYTEDANCE_GRANT_TYPE') || 'client_credential',
}; };
} }
@@ -160,24 +158,42 @@ export class DouyinContentAdapter extends BaseContentAdapter {
} }
} }
/**
* 将图片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> { private async getAccessToken(): Promise<string> {
console.log('getAccessToken called - douyinAuthClient:', this.douyinAuthClient);
console.log('douyinAuthClient type:', typeof this.douyinAuthClient);
if (!this.douyinAuthClient) { if (!this.douyinAuthClient) {
throw new Error('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)
}
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) * 调用抖音审核API (使用SDK v3)
@@ -192,19 +208,26 @@ export class DouyinContentAdapter extends BaseContentAdapter {
): Promise<ContentAuditResult> { ): Promise<ContentAuditResult> {
// 确保有有效的access token // 确保有有效的access token
const accessToken = await this.getAccessToken(); const accessToken = await this.getAccessToken();
const url = `https://open.douyin.com/api/apps/v1/censor/image/` const url = `https://open.douyin.com/api/apps/v1/censor/image/`;
// 将图片转化为 base64格式的图片
const imageData = await this.imageUrlToBase64(auditData.imageUrl);
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post(url, { this.httpService.post(
app_id: this.douyinConfig.appId, url,
image: auditData.imageUrl {
}, { app_id: this.douyinConfig.appId,
headers: { image_data: imageData,
[`access_token`]: accessToken, [`access-token`]: accessToken,
[`content-type`]: `application/json` },
} {
}) headers: {
[`access-token`]: accessToken,
[`content-type`]: `application/json`,
},
},
),
); );
console.log({ response: response.data }); const data = response.data;
return this.parseDouyinSDKResponse(response.data, auditData.taskId || ''); return this.parseDouyinSDKResponse(response.data, auditData.taskId || '');
} }
@@ -237,16 +260,16 @@ export class DouyinContentAdapter extends BaseContentAdapter {
// SDK v3的响应格式不同需要根据实际返回结果进行适配 // SDK v3的响应格式不同需要根据实际返回结果进行适配
const isHit = const isHit =
responseData.predicts?.some((predict) => predict.hit) || false; responseData.predicts?.some((predict) => predict.hit) || false;
console.log(responseData.predicts)
const conclusion = isHit ? 2 : 1; // 2: 不合规, 1: 合规 const conclusion = isHit ? 2 : 1; // 2: 不合规, 1: 合规
const confidence = 80; // SDK v3暂不提供具体置信度使用默认值
return { return {
taskId: taskId, taskId: taskId,
status: AuditStatus.COMPLETED, // SDK v3直接返回结果 status: AuditStatus.COMPLETED, // SDK v3直接返回结果
conclusion: this.mapDouyinConclusion(conclusion), conclusion: this.mapDouyinConclusion(conclusion),
confidence: confidence, confidence: 100,
details: this.mapDouyinSDKDetails(responseData.predicts || []), details: this.mapDouyinSDKDetails(responseData.predicts || []),
riskLevel: this.calculateRiskLevel(conclusion, confidence), riskLevel: this.calculateRiskLevel(conclusion, 100),
suggestion: this.getSuggestion(conclusion), suggestion: this.getSuggestion(conclusion),
platformData: responseData, platformData: responseData,
timestamp: new Date(), timestamp: new Date(),

View File

@@ -22,6 +22,7 @@ import { ContentModerationController } from './controllers/content-moderation.co
// 守卫 // 守卫
import { ContentAuditGuard } from './guards/content-audit.guard'; import { ContentAuditGuard } from './guards/content-audit.guard';
import { ConfigService } from '@nestjs/config';
@Module({ @Module({
imports: [ imports: [
@@ -30,9 +31,12 @@ import { ContentAuditGuard } from './guards/content-audit.guard';
timeout: 30000, timeout: 30000,
maxRedirects: 3, maxRedirects: 3,
}), }),
JwtModule.register({ JwtModule.registerAsync({
secret: process.env.JWT_SECRET || 'default-secret', useFactory: (config: ConfigService) => ({
signOptions: { expiresIn: '1d' }, secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: '1d' },
}),
inject: [ConfigService],
}), }),
PlatformModule, PlatformModule,
], ],
@@ -51,6 +55,11 @@ import { ContentAuditGuard } from './guards/content-audit.guard';
ContentAuditGuard, ContentAuditGuard,
], ],
controllers: [ContentModerationController], controllers: [ContentModerationController],
exports: [UnifiedContentService, ContentAdapterFactory, ContentAuditGuard, DouyinAuthClient], exports: [
UnifiedContentService,
ContentAdapterFactory,
ContentAuditGuard,
DouyinAuthClient,
],
}) })
export class ContentModerationModule {} export class ContentModerationModule {}

View File

@@ -14,7 +14,6 @@ export class ContentAdapterFactory {
constructor( constructor(
private readonly douyinAdapter: DouyinContentAdapter, private readonly douyinAdapter: DouyinContentAdapter,
private readonly wechatAdapter: WechatContentAdapter,
private readonly enhancedWechatAdapter: EnhancedWechatContentAdapter, private readonly enhancedWechatAdapter: EnhancedWechatContentAdapter,
) { ) {
// 注册所有可用的审核适配器 // 注册所有可用的审核适配器

View File

@@ -28,6 +28,7 @@ export class PlatformAuthGuard implements CanActivate {
// 验证平台令牌有效性 // 验证平台令牌有效性
const adapter = this.platformAdapterFactory.getAdapter(payload.platform); const adapter = this.platformAdapterFactory.getAdapter(payload.platform);
const isValid = await adapter.validateToken(token); const isValid = await adapter.validateToken(token);
if (!isValid) { if (!isValid) {
@@ -43,8 +44,21 @@ export class PlatformAuthGuard implements CanActivate {
return true; return true;
} catch (error) { } catch (error) {
console.log({ token }); console.error('PlatformAuthGuard - token验证失败:', {
throw new UnauthorizedException('令牌验证失败'); error: error.message,
name: error.name,
tokenPreview: token ? token.substring(0, 20) + '...' : 'no token',
});
// JWT解析失败的具体错误信息
if (error.name === 'JsonWebTokenError') {
throw new UnauthorizedException('JWT格式无效: ' + error.message);
} else if (error.name === 'TokenExpiredError') {
throw new UnauthorizedException('JWT已过期');
} else if (error.name === 'NotBeforeError') {
throw new UnauthorizedException('JWT尚未生效');
}
throw new UnauthorizedException('令牌验证失败: ' + error.message);
} }
} }

View File

@@ -4,7 +4,7 @@ console.log('测试降级后的 @open-dy/open_api_sdk@1.0.0 包...');
try { try {
console.log('1. 尝试导入包...'); console.log('1. 尝试导入包...');
const dyOpenAiSdk = require('@open-dy/open_api_credential'); const dyOpenAiSdk = require('@open-dy/open_api_sdk');
console.log('✅ 2. 包导入成功!'); console.log('✅ 2. 包导入成功!');
console.log('3. 包内容:', typeof dyOpenAiSdk); console.log('3. 包内容:', typeof dyOpenAiSdk);
console.log('4. 包属性:', Object.keys(dyOpenAiSdk || {})); console.log('4. 包属性:', Object.keys(dyOpenAiSdk || {}));