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