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/swagger": "^11.2.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",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",

View File

@ -1,46 +1,94 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Client from '@open-dy/open_api_credential';
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
/**
HTTP URL
https://developer.toutiao.com/api/apps/v2/token
HTTP Method POST
HTTP URL https://open.douyin.com/oauth/access_token/
HTTP Method POST
content-type: application/json
content-type: application/x-www-form-urlencoded"
body:
- client_key
- client_secret
- appid
- 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()
export class DouyinAuthClient {
private readonly client: Client;
constructor(private config: ConfigService) {
console.log('DouyinAuthClient constructor - creating client with:', {
clientKey: this.config.get('BYTEDANCE_APP_ID') || '',
clientSecret: this.config.get('BYTEDANCE_APP_SECRET') ? '***' : 'empty'
});
this.client = new Client({
clientKey: this.config.get('BYTEDANCE_APP_ID') || '',
clientSecret: this.config.get('BYTEDANCE_APP_SECRET') || '',
});
console.log('DouyinAuthClient constructor - client created:', !!this.client);
console.log('DouyinAuthClient constructor - client.getAccessToken type:', typeof this.client.getAccessToken);
private appid: string;
private secret: string;
private grantType: string = 'client_credential';
private accessToken: string | null = null;
private tokenExpires: number = 0;
constructor(
private http: HttpService,
private config: ConfigService,
) {
this.appid = this.config.get('BYTEDANCE_APP_ID') || '';
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() {
console.log('DouyinAuthClient.getAccessToken called');
console.log('client:', !!this.client);
console.log('client.getAccessToken type:', typeof this.client.getAccessToken);
try {
const result = await this.client.getAccessToken();
console.log('getAccessToken result:', result);
return result;
} catch (error) {
console.error('getAccessToken error:', error);
throw error;
}
try {
const response = await firstValueFrom(
this.http.post<TokenResponse>(
'https://open.douyin.com/oauth/client_token',
{
client_key: this.appid,
grant_type: this.grantType,
client_secret: this.secret,
},
{
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()
export class DouyinContentAdapter extends BaseContentAdapter {
platform = PlatformType.BYTEDANCE;
protected readonly douyinAuthClient: DouyinAuthClient;
private douyinConfig: {
clientKey: string;
clientSecret: string;
@ -30,25 +30,23 @@ export class DouyinContentAdapter extends BaseContentAdapter {
constructor(
protected readonly httpService: HttpService,
protected readonly configService: ConfigService,
protected readonly douyinAuthClient: DouyinAuthClient,
@InjectRepository(ContentAuditLogEntity)
protected readonly auditLogRepository: Repository<ContentAuditLogEntity>,
) {
super(httpService, configService, auditLogRepository);
// 调试:检查 douyinAuthClient 是否正确注入
console.log('DouyinContentAdapter constructor - douyinAuthClient:', this.douyinAuthClient);
console.log('douyinAuthClient type:', typeof this.douyinAuthClient);
if (this.douyinAuthClient) {
console.log('douyinAuthClient.getAccessToken type:', typeof this.douyinAuthClient.getAccessToken);
}
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',
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> {
console.log('getAccessToken called - douyinAuthClient:', this.douyinAuthClient);
console.log('douyinAuthClient type:', typeof this.douyinAuthClient);
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)
}
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)
@ -192,19 +208,26 @@ export class DouyinContentAdapter extends BaseContentAdapter {
): Promise<ContentAuditResult> {
// 确保有有效的access token
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(
this.httpService.post(url, {
app_id: this.douyinConfig.appId,
image: auditData.imageUrl
}, {
headers: {
[`access_token`]: accessToken,
[`content-type`]: `application/json`
}
})
this.httpService.post(
url,
{
app_id: this.douyinConfig.appId,
image_data: imageData,
[`access-token`]: accessToken,
},
{
headers: {
[`access-token`]: accessToken,
[`content-type`]: `application/json`,
},
},
),
);
console.log({ response: response.data });
const data = response.data;
return this.parseDouyinSDKResponse(response.data, auditData.taskId || '');
}
@ -237,16 +260,16 @@ export class DouyinContentAdapter extends BaseContentAdapter {
// SDK v3的响应格式不同需要根据实际返回结果进行适配
const isHit =
responseData.predicts?.some((predict) => predict.hit) || false;
console.log(responseData.predicts)
const conclusion = isHit ? 2 : 1; // 2: 不合规, 1: 合规
const confidence = 80; // SDK v3暂不提供具体置信度使用默认值
return {
taskId: taskId,
status: AuditStatus.COMPLETED, // SDK v3直接返回结果
conclusion: this.mapDouyinConclusion(conclusion),
confidence: confidence,
confidence: 100,
details: this.mapDouyinSDKDetails(responseData.predicts || []),
riskLevel: this.calculateRiskLevel(conclusion, confidence),
riskLevel: this.calculateRiskLevel(conclusion, 100),
suggestion: this.getSuggestion(conclusion),
platformData: responseData,
timestamp: new Date(),

View File

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

View File

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

View File

@ -28,6 +28,7 @@ export class PlatformAuthGuard implements CanActivate {
// 验证平台令牌有效性
const adapter = this.platformAdapterFactory.getAdapter(payload.platform);
const isValid = await adapter.validateToken(token);
if (!isValid) {
@ -43,8 +44,21 @@ export class PlatformAuthGuard implements CanActivate {
return true;
} catch (error) {
console.log({ token });
throw new UnauthorizedException('令牌验证失败');
console.error('PlatformAuthGuard - token验证失败:', {
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 {
console.log('1. 尝试导入包...');
const dyOpenAiSdk = require('@open-dy/open_api_credential');
const dyOpenAiSdk = require('@open-dy/open_api_sdk');
console.log('✅ 2. 包导入成功!');
console.log('3. 包内容:', typeof dyOpenAiSdk);
console.log('4. 包属性:', Object.keys(dyOpenAiSdk || {}));