feat: 实现imageUrlToBase64图片转换功能和优化抖音SDK集成
- 添加imageUrlToBase64方法将图片URL转换为Base64格式 - 重构DouyinAuthClient移除第三方SDK依赖,使用原生HTTP请求 - 更新抖音内容审核API调用逻辑,使用image_data字段 - 优化错误处理和日志输出 - 修复依赖注入和模块配置问题
This commit is contained in:
parent
8eeedb444f
commit
547bd1eba4
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ export class ContentAdapterFactory {
|
|||
|
||||
constructor(
|
||||
private readonly douyinAdapter: DouyinContentAdapter,
|
||||
private readonly wechatAdapter: WechatContentAdapter,
|
||||
private readonly enhancedWechatAdapter: EnhancedWechatContentAdapter,
|
||||
) {
|
||||
// 注册所有可用的审核适配器
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || {}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue