feat: 添加图片内容审核功能到模板执行流程

- 集成统一内容审核服务到模板控制器
- 在模板执行前进行图片内容审核
- 审核未通过时返回403错误和详细原因
- 添加内容审核模块和相关服务
- 创建内容审核日志表迁移文件
This commit is contained in:
imeepos
2025-09-05 14:48:49 +08:00
parent 1e31514f11
commit 00bf807b31
19 changed files with 2393 additions and 6 deletions

View File

@@ -16,6 +16,7 @@ import { AdWatchEntity } from './entities/ad-watch.entity';
import { N8nTemplateFactoryService } from './services/n8n-template-factory.service';
import { TemplateController } from './controllers/template.controller';
import { PlatformModule } from './platform/platform.module';
import { ContentModerationModule } from './content-moderation/content-moderation.module';
import { HttpModule } from '@nestjs/axios';
import { JwtModule } from '@nestjs/jwt';
@@ -49,6 +50,7 @@ import { JwtModule } from '@nestjs/jwt';
AdWatchEntity,
]),
PlatformModule,
ContentModerationModule,
],
controllers: [AppController, TemplateController],
providers: [TemplateService, TemplateManager, N8nTemplateFactoryService],

View File

@@ -0,0 +1,332 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DouyinContentAdapter } from '../adapters/douyin-content.adapter';
import { ContentAuditLogEntity } from '../entities/content-audit-log.entity';
import { PlatformType } from '../../entities/platform-user.entity';
import {
ImageAuditRequest,
AuditStatus,
AuditConclusion
} from '../interfaces/content-moderation.interface';
describe('DouyinContentAdapter', () => {
let adapter: DouyinContentAdapter;
let httpService: HttpService;
let configService: ConfigService;
let repository: Repository<ContentAuditLogEntity>;
const mockHttpService = {
axiosRef: {
post: jest.fn(),
head: jest.fn(),
get: jest.fn(),
},
};
const mockConfigService = {
get: jest.fn(),
};
const mockRepository = {
save: jest.fn(),
update: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DouyinContentAdapter,
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: getRepositoryToken(ContentAuditLogEntity),
useValue: mockRepository,
},
],
}).compile();
adapter = module.get<DouyinContentAdapter>(DouyinContentAdapter);
httpService = module.get<HttpService>(HttpService);
configService = module.get<ConfigService>(ConfigService);
repository = module.get<Repository<ContentAuditLogEntity>>(
getRepositoryToken(ContentAuditLogEntity),
);
// Setup default config mock returns
mockConfigService.get.mockImplementation((key: string, defaultValue?: string) => {
const configs = {
'BYTEDANCE_APP_ID': 'test_app_id',
'BYTEDANCE_APP_SECRET': 'test_app_secret',
'BYTEDANCE_AUDIT_API_URL': 'https://test.api.com/audit',
'AUDIT_CALLBACK_URL': 'https://test.callback.com',
};
return configs[key] || defaultValue;
});
jest.clearAllMocks();
});
it('should be defined', () => {
expect(adapter).toBeDefined();
expect(adapter.platform).toBe(PlatformType.BYTEDANCE);
});
describe('auditImage', () => {
it('should audit image successfully', async () => {
const auditData: ImageAuditRequest = {
imageUrl: 'https://example.com/image.jpg',
userId: 'user123',
businessType: 'template_execution',
};
const mockAuditLog = {
id: 1,
taskId: 'audit_bytedance_123456789_abc123def',
userId: 'user123',
platform: PlatformType.BYTEDANCE,
status: AuditStatus.PENDING,
};
const mockDouyinResponse = {
err_no: 0,
err_tips: 'success',
log_id: 'log123',
data: {
task_id: 'audit_bytedance_123456789_abc123def',
status: 1, // completed
conclusion: 1, // pass
confidence: 95,
details: [],
},
};
// Mock image URL validation
mockHttpService.axiosRef.head.mockResolvedValue({
headers: { 'content-type': 'image/jpeg' },
});
// Mock audit log creation
mockRepository.save.mockResolvedValue(mockAuditLog);
// Mock API call
mockHttpService.axiosRef.post.mockResolvedValue({
data: mockDouyinResponse,
});
// Mock audit log update
mockRepository.update.mockResolvedValue(undefined);
const result = await adapter.auditImage(auditData);
expect(result).toMatchObject({
taskId: mockAuditLog.taskId,
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.PASS,
confidence: 95,
});
expect(mockHttpService.axiosRef.head).toHaveBeenCalledWith(
auditData.imageUrl,
{ timeout: 5000 }
);
expect(mockRepository.save).toHaveBeenCalled();
expect(mockHttpService.axiosRef.post).toHaveBeenCalledWith(
'https://test.api.com/audit',
expect.objectContaining({
app_id: 'test_app_id',
image_url: auditData.imageUrl,
task_id: mockAuditLog.taskId,
callback_url: 'https://test.callback.com',
}),
expect.any(Object)
);
expect(mockRepository.update).toHaveBeenCalled();
});
it('should handle invalid image URL', async () => {
const auditData: ImageAuditRequest = {
imageUrl: 'https://example.com/invalid.jpg',
userId: 'user123',
};
const mockAuditLog = {
taskId: 'test_task_id',
};
mockRepository.save.mockResolvedValue(mockAuditLog);
mockHttpService.axiosRef.head.mockRejectedValue(new Error('Invalid URL'));
await expect(adapter.auditImage(auditData)).rejects.toThrow('无效的图片URL');
});
it('should handle Douyin API error', async () => {
const auditData: ImageAuditRequest = {
imageUrl: 'https://example.com/image.jpg',
userId: 'user123',
};
const mockAuditLog = {
taskId: 'test_task_id',
};
mockRepository.save.mockResolvedValue(mockAuditLog);
mockHttpService.axiosRef.head.mockResolvedValue({
headers: { 'content-type': 'image/jpeg' },
});
mockHttpService.axiosRef.post.mockResolvedValue({
data: {
err_no: 40001,
err_tips: 'Invalid app_id',
},
});
await expect(adapter.auditImage(auditData)).rejects.toThrow();
});
});
describe('queryAuditResult', () => {
it('should return completed audit result from database', async () => {
const taskId = 'test_task_id';
const mockAuditLog = {
taskId: taskId,
platform: PlatformType.BYTEDANCE,
status: AuditStatus.COMPLETED,
auditResult: {
taskId: taskId,
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.PASS,
confidence: 95,
},
};
mockRepository.findOne.mockResolvedValue(mockAuditLog);
const result = await adapter.queryAuditResult(taskId);
expect(result).toEqual(mockAuditLog.auditResult);
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { taskId, platform: PlatformType.BYTEDANCE }
});
});
it('should query platform API for pending result', async () => {
const taskId = 'test_task_id';
const mockAuditLog = {
taskId: taskId,
platform: PlatformType.BYTEDANCE,
status: AuditStatus.PROCESSING,
};
const mockApiResponse = {
err_no: 0,
data: {
task_id: taskId,
status: 1, // completed
conclusion: 1, // pass
confidence: 95,
details: [],
},
};
mockRepository.findOne.mockResolvedValue(mockAuditLog);
mockHttpService.axiosRef.post.mockResolvedValue({
data: mockApiResponse,
});
mockRepository.update.mockResolvedValue(undefined);
const result = await adapter.queryAuditResult(taskId);
expect(result.status).toBe(AuditStatus.COMPLETED);
expect(mockHttpService.axiosRef.post).toHaveBeenCalledWith(
'https://test.api.com/audit/query',
expect.objectContaining({
app_id: 'test_app_id',
task_id: taskId,
}),
expect.any(Object)
);
});
it('should throw error for non-existent task', async () => {
const taskId = 'non_existent_task';
mockRepository.findOne.mockResolvedValue(null);
await expect(adapter.queryAuditResult(taskId)).rejects.toThrow('审核任务不存在');
});
});
describe('auditImageBatch', () => {
it('should handle batch audit with mixed results', async () => {
const auditDataList: ImageAuditRequest[] = [
{ imageUrl: 'https://example.com/image1.jpg', userId: 'user123' },
{ imageUrl: 'https://example.com/image2.jpg', userId: 'user123' },
];
// Mock successful first image, failed second image
jest.spyOn(adapter, 'auditImage')
.mockResolvedValueOnce({
taskId: 'task1',
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.PASS,
confidence: 95,
details: [],
riskLevel: 'low' as any,
suggestion: 'pass' as any,
timestamp: new Date(),
})
.mockRejectedValueOnce(new Error('API Error'));
const results = await adapter.auditImageBatch(auditDataList);
expect(results).toHaveLength(2);
expect(results[0].status).toBe(AuditStatus.COMPLETED);
expect(results[1].status).toBe(AuditStatus.FAILED);
});
});
describe('handleAuditCallback', () => {
it('should process audit callback correctly', async () => {
const callbackData = {
task_id: 'test_task_id',
status: 1,
conclusion: 2,
confidence: 85,
details: [
{
type: 'politics',
label: 'sensitive_content',
confidence: 85,
description: 'Contains sensitive political content',
},
],
};
mockRepository.update.mockResolvedValue(undefined);
await adapter.handleAuditCallback(callbackData);
expect(mockRepository.update).toHaveBeenCalledWith(
{ taskId: 'test_task_id' },
expect.objectContaining({
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.REJECT,
confidence: 85,
})
);
});
});
});

View File

@@ -0,0 +1,267 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import { UnifiedContentService } from '../services/unified-content.service';
import { ContentAdapterFactory } from '../services/content-adapter.factory';
import { ContentAuditLogEntity } from '../entities/content-audit-log.entity';
import { PlatformType } from '../../entities/platform-user.entity';
import {
ImageAuditRequest,
ContentAuditResult,
AuditStatus,
AuditConclusion,
RiskLevel,
AuditSuggestion
} from '../interfaces/content-moderation.interface';
describe('UnifiedContentService', () => {
let service: UnifiedContentService;
let contentAdapterFactory: ContentAdapterFactory;
let auditLogRepository: Repository<ContentAuditLogEntity>;
const mockAdapter = {
platform: PlatformType.BYTEDANCE,
auditImage: jest.fn(),
auditImageBatch: jest.fn(),
queryAuditResult: jest.fn(),
handleAuditCallback: jest.fn(),
};
const mockRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UnifiedContentService,
{
provide: ContentAdapterFactory,
useValue: {
getAdapter: jest.fn().mockReturnValue(mockAdapter),
getSupportedPlatforms: jest.fn().mockReturnValue([PlatformType.BYTEDANCE, PlatformType.WECHAT]),
},
},
{
provide: getRepositoryToken(ContentAuditLogEntity),
useValue: mockRepository,
},
],
}).compile();
service = module.get<UnifiedContentService>(UnifiedContentService);
contentAdapterFactory = module.get<ContentAdapterFactory>(ContentAdapterFactory);
auditLogRepository = module.get<Repository<ContentAuditLogEntity>>(
getRepositoryToken(ContentAuditLogEntity),
);
// Clear all mocks before each test
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('auditImage', () => {
it('should audit image successfully', async () => {
const auditData: ImageAuditRequest = {
imageUrl: 'https://example.com/image.jpg',
userId: 'user123',
businessType: 'template_execution',
};
const expectedResult: ContentAuditResult = {
taskId: 'task123',
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.PASS,
confidence: 95,
details: [],
riskLevel: RiskLevel.LOW,
suggestion: AuditSuggestion.PASS,
timestamp: new Date(),
};
mockAdapter.auditImage.mockResolvedValue(expectedResult);
const result = await service.auditImage(PlatformType.BYTEDANCE, auditData);
expect(contentAdapterFactory.getAdapter).toHaveBeenCalledWith(PlatformType.BYTEDANCE);
expect(mockAdapter.auditImage).toHaveBeenCalledWith(auditData);
expect(result).toEqual(expectedResult);
});
it('should handle adapter error', async () => {
const auditData: ImageAuditRequest = {
imageUrl: 'https://example.com/image.jpg',
userId: 'user123',
};
mockAdapter.auditImage.mockRejectedValue(new Error('API Error'));
await expect(service.auditImage(PlatformType.BYTEDANCE, auditData))
.rejects.toThrow('API Error');
});
});
describe('auditImageBatch', () => {
it('should audit multiple images successfully', async () => {
const auditDataList: ImageAuditRequest[] = [
{ imageUrl: 'https://example.com/image1.jpg', userId: 'user123' },
{ imageUrl: 'https://example.com/image2.jpg', userId: 'user123' },
];
const expectedResults: ContentAuditResult[] = [
{
taskId: 'task123',
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.PASS,
confidence: 95,
details: [],
riskLevel: RiskLevel.LOW,
suggestion: AuditSuggestion.PASS,
timestamp: new Date(),
},
{
taskId: 'task124',
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.REJECT,
confidence: 85,
details: [],
riskLevel: RiskLevel.HIGH,
suggestion: AuditSuggestion.BLOCK,
timestamp: new Date(),
},
];
mockAdapter.auditImageBatch.mockResolvedValue(expectedResults);
const results = await service.auditImageBatch(PlatformType.BYTEDANCE, auditDataList);
expect(mockAdapter.auditImageBatch).toHaveBeenCalledWith(auditDataList);
expect(results).toEqual(expectedResults);
});
});
describe('getUserAuditHistory', () => {
it('should return user audit history', async () => {
const userId = 'user123';
const mockHistory = [
{
id: 1,
taskId: 'task123',
userId: 'user123',
platform: PlatformType.BYTEDANCE,
status: AuditStatus.COMPLETED,
conclusion: AuditConclusion.PASS,
createdAt: new Date(),
},
];
mockRepository.find.mockResolvedValue(mockHistory);
const result = await service.getUserAuditHistory(userId, 10);
expect(mockRepository.find).toHaveBeenCalledWith({
where: { userId },
order: { createdAt: 'DESC' },
take: 10,
});
expect(result).toEqual(mockHistory);
});
});
describe('isContentApproved', () => {
it('should return true for approved content', async () => {
const taskId = 'task123';
const mockAuditLog = {
taskId: 'task123',
conclusion: 'pass',
};
mockRepository.findOne.mockResolvedValue(mockAuditLog);
const result = await service.isContentApproved(taskId);
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { taskId }
});
expect(result).toBe(true);
});
it('should return false for rejected content', async () => {
const taskId = 'task123';
const mockAuditLog = {
taskId: 'task123',
conclusion: 'reject',
};
mockRepository.findOne.mockResolvedValue(mockAuditLog);
const result = await service.isContentApproved(taskId);
expect(result).toBe(false);
});
it('should throw error for non-existent audit log', async () => {
const taskId = 'task123';
mockRepository.findOne.mockResolvedValue(null);
await expect(service.isContentApproved(taskId))
.rejects.toThrow(BadRequestException);
});
});
describe('getSupportedPlatforms', () => {
it('should return supported platforms', () => {
const result = service.getSupportedPlatforms();
expect(contentAdapterFactory.getSupportedPlatforms).toHaveBeenCalled();
expect(result).toEqual([PlatformType.BYTEDANCE, PlatformType.WECHAT]);
});
});
describe('getAuditStats', () => {
it('should return audit statistics', async () => {
const mockStats = [
{
platform: 'bytedance',
conclusion: 'pass',
count: '10',
avgConfidence: '95.5',
},
{
platform: 'bytedance',
conclusion: 'reject',
count: '2',
avgConfidence: '85.0',
},
];
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue(mockStats),
};
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const result = await service.getAuditStats(PlatformType.BYTEDANCE);
expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('audit');
expect(mockQueryBuilder.where).toHaveBeenCalledWith('audit.platform = :platform', { platform: PlatformType.BYTEDANCE });
expect(result).toEqual(mockStats);
});
});
});

View File

@@ -0,0 +1,118 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
import { ContentAuditLogEntity } from '../entities/content-audit-log.entity';
import { PlatformType } from '../../entities/platform-user.entity';
import {
IContentModerationAdapter,
ImageAuditRequest,
ContentAuditResult,
AuditStatus,
AuditConclusion
} from '../interfaces/content-moderation.interface';
@Injectable()
export abstract class BaseContentAdapter implements IContentModerationAdapter {
abstract platform: PlatformType;
constructor(
protected readonly httpService: HttpService,
protected readonly configService: ConfigService,
protected readonly auditLogRepository: Repository<ContentAuditLogEntity>,
) {}
/**
* 创建审核日志记录
*/
async createAuditLog(auditData: ImageAuditRequest): Promise<ContentAuditLogEntity> {
const auditLog = new ContentAuditLogEntity();
auditLog.taskId = auditData.taskId || this.generateTaskId();
auditLog.userId = auditData.userId;
auditLog.platform = this.platform;
auditLog.contentType = 'image';
auditLog.contentUrl = auditData.imageUrl;
auditLog.businessType = auditData.businessType || 'default';
auditLog.status = AuditStatus.PENDING;
auditLog.inputParams = auditData;
return this.auditLogRepository.save(auditLog);
}
/**
* 更新审核日志结果
*/
async updateAuditLog(taskId: string, result: ContentAuditResult): Promise<void> {
await this.auditLogRepository.update(
{ taskId },
{
status: result.status,
conclusion: result.conclusion,
confidence: result.confidence,
riskLevel: result.riskLevel,
suggestion: result.suggestion,
auditResult: result as any,
completedAt: new Date(),
}
);
}
/**
* 生成任务ID
*/
protected generateTaskId(): string {
return `audit_${this.platform}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 统一错误处理
*/
protected handleAuditError(error: any, platform: string): Error {
if (error.response?.data) {
const errorData = error.response.data;
// 抖音格式: {err_no, err_tips}
if (errorData.err_no !== undefined) {
return new Error(`${platform}审核错误[${errorData.err_no}]: ${errorData.err_tips}`);
}
// 微信格式: {errcode, errmsg}
if (errorData.errcode) {
return new Error(`${platform}审核错误[${errorData.errcode}]: ${errorData.errmsg}`);
}
}
return new Error(`${platform}审核API调用失败: ${error.message}`);
}
/**
* 验证图片URL有效性
*/
protected async validateImageUrl(imageUrl: string): Promise<boolean> {
try {
const response = await this.httpService.axiosRef.head(imageUrl, { timeout: 5000 });
const contentType = response.headers['content-type'];
return contentType && contentType.startsWith('image/');
} catch (error) {
return false;
}
}
/**
* 获取图片Base64如果平台需要
*/
protected async getImageBase64(imageUrl: string): Promise<string> {
try {
const response = await this.httpService.axiosRef.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 10000
});
return Buffer.from(response.data, 'binary').toString('base64');
} catch (error) {
throw new Error(`获取图片失败: ${error.message}`);
}
}
// 抽象方法 - 子类必须实现
abstract auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult>;
abstract auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]>;
abstract queryAuditResult(taskId: string): Promise<ContentAuditResult>;
abstract handleAuditCallback(callbackData: any): Promise<void>;
}

View File

@@ -0,0 +1,278 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { BaseContentAdapter } from './base-content.adapter';
import { PlatformType } from '../../entities/platform-user.entity';
import {
ImageAuditRequest,
ContentAuditResult,
AuditStatus,
AuditConclusion,
RiskLevel,
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'),
};
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
try {
// 1. 创建审核日志
const auditLog = await this.createAuditLog(auditData);
// 2. 验证图片URL
const isValidImage = await this.validateImageUrl(auditData.imageUrl);
if (!isValidImage) {
throw new Error('无效的图片URL');
}
// 3. 调用抖音审核API
const auditResult = await this.callDouyinAuditAPI({
...auditData,
taskId: auditLog.taskId
});
// 4. 更新审核日志
await this.updateAuditLog(auditLog.taskId, auditResult);
return auditResult;
} catch (error) {
const auditError = this.handleAuditError(error, '抖音');
throw new BadRequestException(`抖音图片审核失败: ${auditError.message}`);
}
}
async auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
// 抖音批量审核实现
const results: ContentAuditResult[] = [];
for (const auditData of auditDataList) {
try {
const result = await this.auditImage(auditData);
results.push(result);
} catch (error) {
// 单个失败不影响其他审核
results.push({
taskId: auditData.taskId || this.generateTaskId(),
status: AuditStatus.FAILED,
conclusion: AuditConclusion.UNCERTAIN,
confidence: 0,
details: [],
riskLevel: RiskLevel.MEDIUM,
suggestion: AuditSuggestion.HUMAN_REVIEW,
timestamp: new Date(),
});
}
}
return results;
}
async queryAuditResult(taskId: string): Promise<ContentAuditResult> {
try {
// 查询数据库中的审核结果
const auditLog = await this.auditLogRepository.findOne({
where: { taskId, platform: this.platform }
});
if (!auditLog) {
throw new Error('审核任务不存在');
}
// 如果审核完成,直接返回结果
if (auditLog.status === AuditStatus.COMPLETED) {
return auditLog.auditResult;
}
// 如果审核中调用平台API查询最新状态
const platformResult = await this.queryDouyinAuditStatus(taskId);
// 更新数据库
if (platformResult.status === AuditStatus.COMPLETED) {
await this.updateAuditLog(taskId, platformResult);
}
return platformResult;
} catch (error) {
throw new BadRequestException(`查询审核结果失败: ${error.message}`);
}
}
async handleAuditCallback(callbackData: any): Promise<void> {
try {
// 处理抖音审核回调
const { task_id, status, conclusion, confidence, details } = callbackData;
const auditResult: ContentAuditResult = {
taskId: task_id,
status: this.mapDouyinStatus(status),
conclusion: this.mapDouyinConclusion(conclusion),
confidence: confidence,
details: this.mapDouyinDetails(details),
riskLevel: this.calculateRiskLevel(conclusion, confidence),
suggestion: this.getSuggestion(conclusion),
platformData: callbackData,
timestamp: new Date(),
};
await this.updateAuditLog(task_id, auditResult);
} catch (error) {
console.error('处理抖音审核回调失败:', error);
}
}
/**
* 调用抖音审核API
*/
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
};
const response = await this.httpService.axiosRef.post(
this.douyinConfig.auditApiUrl,
requestData,
{
headers: {
'Content-Type': 'application/json',
// 添加认证头部
}
}
);
if (response.data.err_no !== 0) {
throw new Error(`抖音审核API错误: ${response.data.err_tips}`);
}
return this.parseDouyinResponse(response.data, auditData.taskId || '');
}
/**
* 查询抖音审核状态
*/
private async queryDouyinAuditStatus(taskId: string): Promise<ContentAuditResult> {
const queryData = {
app_id: this.douyinConfig.appId,
task_id: taskId,
};
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}`);
}
return this.parseDouyinResponse(response.data, taskId);
}
/**
* 解析抖音响应
*/
private parseDouyinResponse(responseData: DouyinAuditResponse, taskId: string): ContentAuditResult {
const data = responseData.data;
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),
platformData: responseData,
timestamp: new Date(),
};
}
/**
* 映射抖音状态到标准状态
*/
private mapDouyinStatus(status: number): AuditStatus {
switch (status) {
case 0: return AuditStatus.PROCESSING;
case 1: return AuditStatus.COMPLETED;
default: return AuditStatus.FAILED;
}
}
/**
* 映射抖音结论到标准结论
*/
private mapDouyinConclusion(conclusion: number): AuditConclusion {
switch (conclusion) {
case 1: return AuditConclusion.PASS;
case 2: return AuditConclusion.REJECT;
case 3: return AuditConclusion.REVIEW;
case 4:
default: return AuditConclusion.UNCERTAIN;
}
}
/**
* 映射抖音详细信息
*/
private mapDouyinDetails(details: any[]): any[] {
return details?.map(detail => ({
type: detail.type,
label: detail.label,
confidence: detail.confidence,
description: detail.description,
})) || [];
}
/**
* 计算风险等级
*/
private calculateRiskLevel(conclusion: number, confidence: number): RiskLevel {
if (conclusion === 1) return RiskLevel.LOW;
if (conclusion === 2 && confidence > 80) return RiskLevel.CRITICAL;
if (conclusion === 2 && confidence > 60) return RiskLevel.HIGH;
if (conclusion === 3) return RiskLevel.MEDIUM;
return RiskLevel.MEDIUM;
}
/**
* 获取建议操作
*/
private getSuggestion(conclusion: number): AuditSuggestion {
switch (conclusion) {
case 1: return AuditSuggestion.PASS;
case 2: return AuditSuggestion.BLOCK;
case 3: return AuditSuggestion.HUMAN_REVIEW;
case 4:
default: return AuditSuggestion.HUMAN_REVIEW;
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './base-content.adapter';
export * from './douyin-content.adapter';
export * from './wechat-content.adapter';

View File

@@ -0,0 +1,274 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { BaseContentAdapter } from './base-content.adapter';
import { PlatformType } from '../../entities/platform-user.entity';
import {
ImageAuditRequest,
ContentAuditResult,
AuditStatus,
AuditConclusion,
RiskLevel,
AuditSuggestion
} from '../interfaces/content-moderation.interface';
interface WechatAuditResponse {
errcode: number;
errmsg: string;
trace_id: string;
result: {
suggest: string; // pass: 合规, risky: 不合规, review: 疑似
label: number; // 命中标签枚举值100 正常10001 广告20001 时政20002 色情20003 辱骂...
detail: Array<{
strategy: string; // 策略类型
errcode: number; // 错误码
suggest: string; // 建议值
label: number; // 命中标签枚举值
prob: number; // 0-100代表置信度
}>;
};
}
@Injectable()
export class WechatContentAdapter extends BaseContentAdapter {
platform = PlatformType.WECHAT;
private readonly wechatConfig = {
appId: this.configService.get('WECHAT_APP_ID'),
appSecret: this.configService.get('WECHAT_APP_SECRET'),
auditApiUrl: this.configService.get('WECHAT_AUDIT_API_URL', 'https://api.weixin.qq.com/wxa/img_sec_check'),
};
async auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
try {
// 1. 创建审核日志
const auditLog = await this.createAuditLog(auditData);
// 2. 验证图片URL
const isValidImage = await this.validateImageUrl(auditData.imageUrl);
if (!isValidImage) {
throw new Error('无效的图片URL');
}
// 3. 调用微信审核API
const auditResult = await this.callWechatAuditAPI({
...auditData,
taskId: auditLog.taskId
});
// 4. 更新审核日志
await this.updateAuditLog(auditLog.taskId, auditResult);
return auditResult;
} catch (error) {
const auditError = this.handleAuditError(error, '微信');
throw new BadRequestException(`微信图片审核失败: ${auditError.message}`);
}
}
async auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
// 微信批量审核实现
const results: ContentAuditResult[] = [];
for (const auditData of auditDataList) {
try {
const result = await this.auditImage(auditData);
results.push(result);
} catch (error) {
// 单个失败不影响其他审核
results.push({
taskId: auditData.taskId || this.generateTaskId(),
status: AuditStatus.FAILED,
conclusion: AuditConclusion.UNCERTAIN,
confidence: 0,
details: [],
riskLevel: RiskLevel.MEDIUM,
suggestion: AuditSuggestion.HUMAN_REVIEW,
timestamp: new Date(),
});
}
}
return results;
}
async queryAuditResult(taskId: string): Promise<ContentAuditResult> {
try {
// 微信审核是同步的,直接从数据库查询
const auditLog = await this.auditLogRepository.findOne({
where: { taskId, platform: this.platform }
});
if (!auditLog) {
throw new Error('审核任务不存在');
}
return auditLog.auditResult;
} catch (error) {
throw new BadRequestException(`查询审核结果失败: ${error.message}`);
}
}
async handleAuditCallback(callbackData: any): Promise<void> {
// 微信审核是同步的,不需要处理回调
console.log('微信审核为同步模式,无需处理回调');
}
/**
* 调用微信审核API
*/
private async callWechatAuditAPI(auditData: ImageAuditRequest): Promise<ContentAuditResult> {
// 获取access_token
const accessToken = await this.getAccessToken();
// 微信需要文件上传形式
const imageBuffer = await this.getImageBuffer(auditData.imageUrl);
const formData = new FormData();
formData.append('media', new Blob([new Uint8Array(imageBuffer)]), 'image.jpg');
const response = await this.httpService.axiosRef.post(
`${this.wechatConfig.auditApiUrl}?access_token=${accessToken}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
}
}
);
if (response.data.errcode !== 0) {
throw new Error(`微信审核API错误: ${response.data.errmsg}`);
}
return this.parseWechatResponse(response.data, auditData.taskId || '');
}
/**
* 获取微信访问令牌
*/
private async getAccessToken(): Promise<string> {
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.wechatConfig.appId || ''}&secret=${this.wechatConfig.appSecret || ''}`;
const response = await this.httpService.axiosRef.get(url);
if (response.data.errcode) {
throw new Error(`获取微信访问令牌失败: ${response.data.errmsg}`);
}
return response.data.access_token;
}
/**
* 获取图片二进制数据
*/
private async getImageBuffer(imageUrl: string): Promise<Buffer> {
try {
const response = await this.httpService.axiosRef.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 10000
});
return Buffer.from(response.data);
} catch (error) {
throw new Error(`获取图片失败: ${error.message}`);
}
}
/**
* 解析微信响应
*/
private parseWechatResponse(responseData: WechatAuditResponse, taskId: string): ContentAuditResult {
const result = responseData.result;
return {
taskId: taskId,
status: AuditStatus.COMPLETED, // 微信审核是同步的
conclusion: this.mapWechatConclusion(result.suggest),
confidence: this.calculateWechatConfidence(result.detail),
details: this.mapWechatDetails(result.detail),
riskLevel: this.calculateWechatRiskLevel(result.suggest, result.label),
suggestion: this.getWechatSuggestion(result.suggest),
platformData: responseData,
timestamp: new Date(),
};
}
/**
* 映射微信结论到标准结论
*/
private mapWechatConclusion(suggest: string): AuditConclusion {
switch (suggest) {
case 'pass': return AuditConclusion.PASS;
case 'risky': return AuditConclusion.REJECT;
case 'review': return AuditConclusion.REVIEW;
default: return AuditConclusion.UNCERTAIN;
}
}
/**
* 计算微信置信度
*/
private calculateWechatConfidence(details: any[]): number {
if (!details || details.length === 0) return 100;
// 取最高置信度
return Math.max(...details.map(d => d.prob || 0));
}
/**
* 映射微信详细信息
*/
private mapWechatDetails(details: any[]): any[] {
return details?.map(detail => ({
type: detail.strategy || 'unknown',
label: this.getLabelDescription(detail.label),
confidence: detail.prob || 0,
description: `${detail.strategy}: ${this.getLabelDescription(detail.label)}`,
})) || [];
}
/**
* 获取标签描述
*/
private getLabelDescription(label: number): string {
const labelMap: { [key: number]: string } = {
100: '正常',
10001: '广告',
20001: '时政',
20002: '色情',
20003: '辱骂',
20006: '违法犯罪',
20008: '欺诈',
20012: '低俗',
20013: '版权',
21000: '其他',
};
return labelMap[label] || '未知';
}
/**
* 计算风险等级
*/
private calculateWechatRiskLevel(suggest: string, label: number): RiskLevel {
if (suggest === 'pass') return RiskLevel.LOW;
if (suggest === 'risky') {
// 根据具体标签判断风险等级
if ([20001, 20002, 20006].includes(label)) return RiskLevel.CRITICAL;
if ([10001, 20003, 20008].includes(label)) return RiskLevel.HIGH;
return RiskLevel.MEDIUM;
}
if (suggest === 'review') return RiskLevel.MEDIUM;
return RiskLevel.MEDIUM;
}
/**
* 获取建议操作
*/
private getWechatSuggestion(suggest: string): AuditSuggestion {
switch (suggest) {
case 'pass': return AuditSuggestion.PASS;
case 'risky': return AuditSuggestion.BLOCK;
case 'review': return AuditSuggestion.HUMAN_REVIEW;
default: return AuditSuggestion.HUMAN_REVIEW;
}
}
}

View File

@@ -0,0 +1,51 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
// 实体
import { ContentAuditLogEntity } from './entities/content-audit-log.entity';
// 适配器
import { DouyinContentAdapter } from './adapters/douyin-content.adapter';
import { WechatContentAdapter } from './adapters/wechat-content.adapter';
// 服务
import { ContentAdapterFactory } from './services/content-adapter.factory';
import { UnifiedContentService } from './services/unified-content.service';
// 控制器
import { ContentModerationController } from './controllers/content-moderation.controller';
// 守卫
import { ContentAuditGuard } from './guards/content-audit.guard';
@Module({
imports: [
TypeOrmModule.forFeature([ContentAuditLogEntity]),
HttpModule.register({
timeout: 30000,
maxRedirects: 3,
}),
],
providers: [
// 适配器实现
DouyinContentAdapter,
WechatContentAdapter,
// 工厂和服务
ContentAdapterFactory,
UnifiedContentService,
// 守卫
ContentAuditGuard,
],
controllers: [
ContentModerationController,
],
exports: [
UnifiedContentService,
ContentAdapterFactory,
ContentAuditGuard,
],
})
export class ContentModerationModule {}

View File

@@ -0,0 +1,222 @@
import { Controller, Post, Body, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { UnifiedContentService } from '../services/unified-content.service';
import { ImageAuditDto } from '../dto/image-audit.dto';
import {
ContentAuditResponseDto,
BatchAuditResponseDto,
AuditStatsResponseDto
} from '../dto/audit-response.dto';
import { PlatformAuthGuard } from '../../platform/guards/platform-auth.guard';
import { PlatformType } from '../../entities/platform-user.entity';
// 模拟当前用户装饰器,实际应从现有项目中导入
const CurrentUser = () => {
return (target: any, propertyKey: string, parameterIndex: number) => {
// 实际实现应该从请求中提取用户信息
};
};
@ApiTags('内容审核')
@Controller('api/v1/content-moderation')
export class ContentModerationController {
constructor(
private readonly unifiedContentService: UnifiedContentService,
) {}
@Post(':platform/audit-image')
@UseGuards(PlatformAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '图片内容审核' })
@ApiResponse({ status: 200, description: '审核成功', type: ContentAuditResponseDto })
async auditImage(
@Param('platform') platform: PlatformType,
@Body() auditDto: ImageAuditDto,
@CurrentUser() user: any
) {
const auditData = {
...auditDto,
userId: user.userId,
};
const result = await this.unifiedContentService.auditImage(platform, auditData);
return {
code: 200,
message: '审核提交成功',
data: result,
};
}
@Post(':platform/audit-batch')
@UseGuards(PlatformAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '批量图片审核' })
@ApiResponse({ status: 200, description: '批量审核成功', type: BatchAuditResponseDto })
async auditImageBatch(
@Param('platform') platform: PlatformType,
@Body() auditDtoList: ImageAuditDto[],
@CurrentUser() user: any
) {
const auditDataList = auditDtoList.map(dto => ({
...dto,
userId: user.userId,
}));
const results = await this.unifiedContentService.auditImageBatch(platform, auditDataList);
const successCount = results.filter(r => r.status === 'completed').length;
const failureCount = results.length - successCount;
return {
code: 200,
message: '批量审核提交成功',
data: {
totalCount: results.length,
successCount,
failureCount,
results,
},
};
}
@Get(':platform/result/:taskId')
@UseGuards(PlatformAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '查询审核结果' })
@ApiResponse({ status: 200, description: '查询成功', type: ContentAuditResponseDto })
async getAuditResult(
@Param('platform') platform: PlatformType,
@Param('taskId') taskId: string
) {
const result = await this.unifiedContentService.queryAuditResult(platform, taskId);
return {
code: 200,
message: '查询成功',
data: result,
};
}
@Post(':platform/callback')
@ApiOperation({ summary: '审核结果回调' })
async handleCallback(
@Param('platform') platform: PlatformType,
@Body() callbackData: any
) {
await this.unifiedContentService.handleAuditCallback(platform, callbackData);
return {
code: 200,
message: '回调处理成功',
};
}
@Get('history')
@UseGuards(PlatformAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取审核历史' })
async getAuditHistory(
@CurrentUser() user: any,
@Query('limit') limit = 50
) {
const history = await this.unifiedContentService.getUserAuditHistory(user.userId, limit);
return {
code: 200,
message: '获取成功',
data: {
total: history.length,
list: history,
},
};
}
@Get('stats')
@ApiOperation({ summary: '获取审核统计' })
@ApiResponse({ status: 200, description: '获取成功', type: AuditStatsResponseDto })
async getAuditStats(
@Query('platform') platform?: PlatformType,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string
) {
const start = startDate ? new Date(startDate) : undefined;
const end = endDate ? new Date(endDate) : undefined;
const stats = await this.unifiedContentService.getAuditStats(platform, start, end);
// 处理统计数据
const totalAudits = stats.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
const passCount = stats.filter((item: any) => item.conclusion === 'pass')
.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
const rejectCount = stats.filter((item: any) => item.conclusion === 'reject')
.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
const reviewCount = stats.filter((item: any) => item.conclusion === 'review')
.reduce((sum: number, item: any) => sum + parseInt(item.count), 0);
const avgConfidence = stats.reduce((sum: number, item: any) =>
sum + (parseFloat(item.avgConfidence) || 0), 0) / (stats.length || 1);
return {
code: 200,
message: '获取成功',
data: {
totalAudits,
passRate: totalAudits > 0 ? (passCount / totalAudits * 100) : 0,
rejectRate: totalAudits > 0 ? (rejectCount / totalAudits * 100) : 0,
reviewRate: totalAudits > 0 ? (reviewCount / totalAudits * 100) : 0,
averageConfidence: avgConfidence,
platformStats: stats.map((item: any) => ({
platform: item.platform,
count: parseInt(item.count),
conclusion: item.conclusion,
avgConfidence: parseFloat(item.avgConfidence) || 0,
})),
},
};
}
@Get('user-stats')
@UseGuards(PlatformAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取用户审核统计' })
async getUserAuditStats(
@CurrentUser() user: any,
@Query('days') days = 30
) {
const stats = await this.unifiedContentService.getUserAuditStats(user.userId, days);
return {
code: 200,
message: '获取成功',
data: stats,
};
}
@Get('platforms')
@ApiOperation({ summary: '获取支持的审核平台' })
async getSupportedPlatforms() {
const platforms = this.unifiedContentService.getSupportedPlatforms();
return {
code: 200,
message: '获取成功',
data: platforms,
};
}
@Get('check/:taskId')
@ApiOperation({ summary: '检查内容是否通过审核' })
async checkContentApproval(@Param('taskId') taskId: string) {
const isApproved = await this.unifiedContentService.isContentApproved(taskId);
return {
code: 200,
message: '检查成功',
data: {
taskId,
approved: isApproved,
},
};
}
}

View File

@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
import {
AuditStatus,
AuditConclusion,
RiskLevel,
AuditSuggestion
} from '../interfaces/content-moderation.interface';
export class AuditDetailResponseDto {
@ApiProperty({ description: '检测类型' })
type: string;
@ApiProperty({ description: '具体标签' })
label: string;
@ApiProperty({ description: '置信度', example: 85 })
confidence: number;
@ApiProperty({ description: '描述信息' })
description: string;
}
export class ContentAuditResponseDto {
@ApiProperty({ description: '任务ID' })
taskId: string;
@ApiProperty({ enum: AuditStatus, description: '审核状态' })
status: AuditStatus;
@ApiProperty({ enum: AuditConclusion, description: '审核结论' })
conclusion: AuditConclusion;
@ApiProperty({ description: '置信度 (0-100)', example: 85 })
confidence: number;
@ApiProperty({ type: [AuditDetailResponseDto], description: '详细审核结果' })
details: AuditDetailResponseDto[];
@ApiProperty({ enum: RiskLevel, description: '风险等级' })
riskLevel: RiskLevel;
@ApiProperty({ enum: AuditSuggestion, description: '建议操作' })
suggestion: AuditSuggestion;
@ApiProperty({ description: '审核时间' })
timestamp: Date;
}
export class BatchAuditResponseDto {
@ApiProperty({ description: '总数' })
totalCount: number;
@ApiProperty({ description: '成功数' })
successCount: number;
@ApiProperty({ description: '失败数' })
failureCount: number;
@ApiProperty({ type: [ContentAuditResponseDto], description: '审核结果列表' })
results: ContentAuditResponseDto[];
}
export class AuditStatsResponseDto {
@ApiProperty({ description: '总审核数' })
totalAudits: number;
@ApiProperty({ description: '通过率 (%)', example: 85.5 })
passRate: number;
@ApiProperty({ description: '拒绝率 (%)', example: 10.2 })
rejectRate: number;
@ApiProperty({ description: '复审率 (%)', example: 4.3 })
reviewRate: number;
@ApiProperty({ description: '平均置信度', example: 88.7 })
averageConfidence: number;
}
export class PlatformAuditStatsDto {
@ApiProperty({ description: '平台名称' })
platform: string;
@ApiProperty({ description: '审核数量' })
count: number;
@ApiProperty({ description: '通过率 (%)', example: 85.5 })
passRate: number;
@ApiProperty({ description: '平均置信度', example: 88.7 })
averageConfidence: number;
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsUrl } from 'class-validator';
export class ImageAuditDto {
@ApiProperty({
description: '图片URL',
example: 'https://example.com/image.jpg',
})
@IsUrl()
imageUrl: string;
@ApiProperty({
description: '图片Base64可选',
required: false,
})
@IsOptional()
@IsString()
imageBase64?: string;
@ApiProperty({
description: '任务ID可选',
required: false,
})
@IsOptional()
@IsString()
taskId?: string;
@ApiProperty({
description: '业务类型',
example: 'user_avatar',
required: false,
})
@IsOptional()
@IsString()
businessType?: string;
@ApiProperty({
description: '扩展数据',
required: false,
})
@IsOptional()
extraData?: any;
}

View File

@@ -0,0 +1,77 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index
} from 'typeorm';
import { PlatformType } from '../../entities/platform-user.entity';
import {
AuditStatus,
AuditConclusion,
RiskLevel,
AuditSuggestion
} from '../interfaces/content-moderation.interface';
@Entity('content_audit_logs')
@Index(['taskId'])
@Index(['userId'])
@Index(['platform'])
@Index(['status'])
@Index(['conclusion'])
export class ContentAuditLogEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'task_id', unique: true })
taskId: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ type: 'enum', enum: PlatformType })
platform: PlatformType;
@Column({ name: 'content_type', default: 'image' })
contentType: string;
@Column({ name: 'content_url', length: 500 })
contentUrl: string;
@Column({ name: 'business_type', default: 'default' })
businessType: string;
@Column({ type: 'enum', enum: AuditStatus, default: AuditStatus.PENDING })
status: AuditStatus;
@Column({ type: 'enum', enum: AuditConclusion, nullable: true })
conclusion: AuditConclusion;
@Column({ default: 0 })
confidence: number;
@Column({ name: 'risk_level', type: 'enum', enum: RiskLevel, nullable: true })
riskLevel: RiskLevel;
@Column({ type: 'enum', enum: AuditSuggestion, nullable: true })
suggestion: AuditSuggestion;
@Column({ name: 'input_params', type: 'json', nullable: true })
inputParams: any;
@Column({ name: 'audit_result', type: 'json', nullable: true })
auditResult: any;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Column({ name: 'completed_at', nullable: true })
completedAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,32 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UnifiedContentService } from '../services/unified-content.service';
@Injectable()
export class ContentAuditGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly unifiedContentService: UnifiedContentService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { taskId } = request.params;
if (!taskId) {
return true; // 如果没有taskId参数跳过审核检查
}
try {
const isApproved = await this.unifiedContentService.isContentApproved(taskId);
if (!isApproved) {
throw new ForbiddenException('内容审核未通过,无法访问');
}
return true;
} catch (error) {
throw new ForbiddenException('内容审核状态异常');
}
}
}

View File

@@ -0,0 +1,65 @@
import { AuditStatus, AuditConclusion, RiskLevel, AuditSuggestion } from './content-moderation.interface';
export interface AuditResultResponse {
code: number;
message: string;
data: ContentAuditResultData;
}
export interface ContentAuditResultData {
taskId: string;
status: AuditStatus;
conclusion: AuditConclusion;
confidence: number;
riskLevel: RiskLevel;
suggestion: AuditSuggestion;
timestamp: Date;
details?: AuditDetailData[];
}
export interface AuditDetailData {
type: string;
label: string;
confidence: number;
description: string;
}
export interface BatchAuditResponse {
code: number;
message: string;
data: {
totalCount: number;
successCount: number;
failureCount: number;
results: ContentAuditResultData[];
};
}
export interface AuditHistoryResponse {
code: number;
message: string;
data: {
total: number;
list: ContentAuditResultData[];
};
}
export interface AuditStatsResponse {
code: number;
message: string;
data: {
totalAudits: number;
passRate: number;
rejectRate: number;
reviewRate: number;
averageConfidence: number;
platformStats: PlatformAuditStats[];
};
}
export interface PlatformAuditStats {
platform: string;
count: number;
passRate: number;
averageConfidence: number;
}

View File

@@ -0,0 +1,80 @@
import { PlatformType } from '../../entities/platform-user.entity';
export interface IContentModerationAdapter {
platform: PlatformType;
// 图片审核
auditImage(auditData: ImageAuditRequest): Promise<ContentAuditResult>;
// 批量图片审核
auditImageBatch(auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]>;
// 查询审核结果
queryAuditResult(taskId: string): Promise<ContentAuditResult>;
// 异步审核回调处理
handleAuditCallback(callbackData: any): Promise<void>;
}
export interface ImageAuditRequest {
imageUrl: string; // 图片URL
imageBase64?: string; // 图片Base64可选
taskId?: string; // 任务ID
userId: string; // 用户ID
businessType?: string; // 业务类型
extraData?: any; // 扩展数据
}
export interface ContentAuditResult {
taskId: string; // 任务ID
status: AuditStatus; // 审核状态
conclusion: AuditConclusion; // 审核结论
confidence: number; // 置信度 (0-100)
details: AuditDetail[]; // 详细审核结果
riskLevel: RiskLevel; // 风险等级
suggestion: AuditSuggestion; // 建议操作
platformData?: any; // 平台原始数据
timestamp: Date; // 审核时间
}
export enum AuditStatus {
PENDING = 'pending', // 待审核
PROCESSING = 'processing', // 审核中
COMPLETED = 'completed', // 审核完成
FAILED = 'failed', // 审核失败
TIMEOUT = 'timeout' // 审核超时
}
export enum AuditConclusion {
PASS = 'pass', // 通过
REJECT = 'reject', // 拒绝
REVIEW = 'review', // 人工复审
UNCERTAIN = 'uncertain' // 不确定
}
export enum RiskLevel {
LOW = 'low', // 低风险
MEDIUM = 'medium', // 中风险
HIGH = 'high', // 高风险
CRITICAL = 'critical' // 极高风险
}
export enum AuditSuggestion {
PASS = 'pass', // 建议通过
BLOCK = 'block', // 建议拦截
HUMAN_REVIEW = 'human_review', // 建议人工审核
DELETE = 'delete' // 建议删除
}
export interface AuditDetail {
type: string; // 检测类型(色情、暴力、政治等)
label: string; // 具体标签
confidence: number; // 该项置信度
description: string; // 描述信息
position?: { // 位置信息(如果支持)
x: number;
y: number;
width: number;
height: number;
};
}

View File

@@ -0,0 +1,51 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PlatformType } from '../../entities/platform-user.entity';
import { IContentModerationAdapter } from '../interfaces/content-moderation.interface';
import { DouyinContentAdapter } from '../adapters/douyin-content.adapter';
import { WechatContentAdapter } from '../adapters/wechat-content.adapter';
@Injectable()
export class ContentAdapterFactory {
private readonly adapters = new Map<PlatformType, IContentModerationAdapter>();
constructor(
private readonly douyinAdapter: DouyinContentAdapter,
private readonly wechatAdapter: WechatContentAdapter,
) {
// 注册所有可用的审核适配器
this.adapters.set(PlatformType.BYTEDANCE, this.douyinAdapter);
this.adapters.set(PlatformType.WECHAT, this.wechatAdapter);
}
/**
* 根据平台类型获取对应的审核适配器
*/
getAdapter(platform: PlatformType): IContentModerationAdapter {
const adapter = this.adapters.get(platform);
if (!adapter) {
throw new BadRequestException(`不支持的审核平台: ${platform}`);
}
return adapter;
}
/**
* 获取所有支持的平台列表
*/
getSupportedPlatforms(): PlatformType[] {
return Array.from(this.adapters.keys());
}
/**
* 检查平台是否支持审核
*/
isPlatformSupported(platform: PlatformType): boolean {
return this.adapters.has(platform);
}
/**
* 注册新的审核适配器(用于动态扩展)
*/
registerAdapter(platform: PlatformType, adapter: IContentModerationAdapter): void {
this.adapters.set(platform, adapter);
}
}

View File

@@ -0,0 +1,225 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ContentAdapterFactory } from './content-adapter.factory';
import { ContentAuditLogEntity } from '../entities/content-audit-log.entity';
import {
ImageAuditRequest,
ContentAuditResult,
AuditStatus
} from '../interfaces/content-moderation.interface';
import { PlatformType } from '../../entities/platform-user.entity';
@Injectable()
export class UnifiedContentService {
constructor(
private readonly contentAdapterFactory: ContentAdapterFactory,
@InjectRepository(ContentAuditLogEntity)
private readonly auditLogRepository: Repository<ContentAuditLogEntity>,
) {}
/**
* 统一图片审核接口
*/
async auditImage(platform: PlatformType, auditData: ImageAuditRequest): Promise<ContentAuditResult> {
const adapter = this.contentAdapterFactory.getAdapter(platform);
return adapter.auditImage(auditData);
}
/**
* 批量图片审核
*/
async auditImageBatch(platform: PlatformType, auditDataList: ImageAuditRequest[]): Promise<ContentAuditResult[]> {
const adapter = this.contentAdapterFactory.getAdapter(platform);
return adapter.auditImageBatch(auditDataList);
}
/**
* 查询审核结果
*/
async queryAuditResult(platform: PlatformType, taskId: string): Promise<ContentAuditResult> {
const adapter = this.contentAdapterFactory.getAdapter(platform);
return adapter.queryAuditResult(taskId);
}
/**
* 处理审核回调
*/
async handleAuditCallback(platform: PlatformType, callbackData: any): Promise<void> {
const adapter = this.contentAdapterFactory.getAdapter(platform);
return adapter.handleAuditCallback(callbackData);
}
/**
* 获取用户审核历史
*/
async getUserAuditHistory(userId: string, limit = 50): Promise<ContentAuditLogEntity[]> {
return this.auditLogRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
take: limit,
});
}
/**
* 获取审核统计
*/
async getAuditStats(platform?: PlatformType, startDate?: Date, endDate?: Date): Promise<any> {
const queryBuilder = this.auditLogRepository
.createQueryBuilder('audit')
.select('audit.platform', 'platform')
.addSelect('audit.conclusion', 'conclusion')
.addSelect('COUNT(*)', 'count')
.addSelect('AVG(audit.confidence)', 'avgConfidence');
if (platform) {
queryBuilder.where('audit.platform = :platform', { platform });
}
if (startDate) {
queryBuilder.andWhere('audit.createdAt >= :startDate', { startDate });
}
if (endDate) {
queryBuilder.andWhere('audit.createdAt <= :endDate', { endDate });
}
return queryBuilder
.groupBy('audit.platform, audit.conclusion')
.getRawMany();
}
/**
* 获取支持的平台列表
*/
getSupportedPlatforms(): PlatformType[] {
return this.contentAdapterFactory.getSupportedPlatforms();
}
/**
* 检查内容是否通过审核
*/
async isContentApproved(taskId: string): Promise<boolean> {
const auditLog = await this.auditLogRepository.findOne({
where: { taskId }
});
if (!auditLog) {
throw new BadRequestException('审核记录不存在');
}
return auditLog.conclusion === 'pass';
}
/**
* 获取审核详细统计数据
*/
async getDetailedAuditStats(platform?: PlatformType, days = 30): Promise<any> {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const queryBuilder = this.auditLogRepository
.createQueryBuilder('audit')
.select('DATE(audit.createdAt)', 'date')
.addSelect('audit.platform', 'platform')
.addSelect('audit.conclusion', 'conclusion')
.addSelect('COUNT(*)', 'count')
.where('audit.createdAt >= :startDate', { startDate });
if (platform) {
queryBuilder.andWhere('audit.platform = :platform', { platform });
}
const results = await queryBuilder
.groupBy('DATE(audit.createdAt), audit.platform, audit.conclusion')
.orderBy('date', 'DESC')
.getRawMany();
// 处理统计数据
const statsMap = new Map();
results.forEach(item => {
const key = `${item.date}_${item.platform}`;
if (!statsMap.has(key)) {
statsMap.set(key, {
date: item.date,
platform: item.platform,
total: 0,
pass: 0,
reject: 0,
review: 0,
uncertain: 0,
});
}
const stats = statsMap.get(key);
stats.total += parseInt(item.count);
stats[item.conclusion] = parseInt(item.count);
});
return Array.from(statsMap.values());
}
/**
* 获取用户审核统计
*/
async getUserAuditStats(userId: string, days = 30): Promise<any> {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const stats = await this.auditLogRepository
.createQueryBuilder('audit')
.select('audit.conclusion', 'conclusion')
.addSelect('COUNT(*)', 'count')
.addSelect('AVG(audit.confidence)', 'avgConfidence')
.where('audit.userId = :userId', { userId })
.andWhere('audit.createdAt >= :startDate', { startDate })
.groupBy('audit.conclusion')
.getRawMany();
const totalCount = stats.reduce((sum, stat) => sum + parseInt(stat.count), 0);
return {
totalAudits: totalCount,
stats: stats.map(stat => ({
conclusion: stat.conclusion,
count: parseInt(stat.count),
percentage: totalCount > 0 ? (parseInt(stat.count) / totalCount * 100).toFixed(2) : '0.00',
avgConfidence: parseFloat(stat.avgConfidence || '0').toFixed(2),
})),
};
}
/**
* 批量更新审核状态(用于异步任务处理)
*/
async batchUpdateAuditStatus(updates: Array<{taskId: string, status: AuditStatus, result?: any}>): Promise<void> {
const updatePromises = updates.map(update =>
this.auditLogRepository.update(
{ taskId: update.taskId },
{
status: update.status,
auditResult: update.result,
completedAt: update.status === AuditStatus.COMPLETED ? new Date() : undefined,
}
)
);
await Promise.all(updatePromises);
}
/**
* 清理过期审核日志
*/
async cleanupExpiredAuditLogs(daysToKeep = 90): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const result = await this.auditLogRepository
.createQueryBuilder()
.delete()
.where('createdAt < :cutoffDate', { cutoffDate })
.execute();
return result.affected || 0;
}
}

View File

@@ -40,6 +40,8 @@ import { Repository } from 'typeorm';
import { ApiCommonResponses } from '../decorators/api-common-responses.decorator';
import { PlatformAuthGuard } from 'src/platform/guards/platform-auth.guard';
import { ResponseUtil, ApiResponse } from '../utils/response.util';
import { UnifiedContentService } from '../content-moderation/services/unified-content.service';
import { AuditConclusion } from '../content-moderation/interfaces/content-moderation.interface';
@ApiTags('AI模板系统')
@Controller('templates')
@@ -51,6 +53,7 @@ export class TemplateController {
private readonly templateRepository: Repository<N8nTemplateEntity>,
@InjectRepository(TemplateExecutionEntity)
private readonly executionRepository: Repository<TemplateExecutionEntity>,
private readonly unifiedContentService: UnifiedContentService,
) { }
@Post(':templateId/execute')
@@ -114,7 +117,7 @@ export class TemplateController {
@UseGuards(PlatformAuthGuard)
@ApiOperation({
summary: '通过代码执行模板',
description: '根据模板代码执行AI生成任务支持图片和视频生成',
description: '根据模板代码执行AI生成任务支持图片和视频生成。执行前会进行图片内容审核。',
})
@ApiParam({
name: 'code',
@@ -127,6 +130,17 @@ export class TemplateController {
description: '执行成功',
type: TemplateExecuteResponseDto,
})
@SwaggerApiResponse({
status: 403,
description: '图片审核未通过',
schema: {
example: {
code: 403,
message: '图片审核未通过: 包含不当内容',
data: null,
},
},
})
async executeTemplateByCode(
@Param('code') code: string,
@Body() body: { imageUrl: string },
@@ -144,13 +158,41 @@ export class TemplateController {
if (!templateConfig) {
throw new HttpException('Template not found', HttpStatus.NOT_FOUND);
}
const userId = req.user.userId;
const platform = req.user.platform;
// 通过模板代码创建实例并执行
// 1. 先进行图片内容审核
const auditResult = await this.unifiedContentService.auditImage(
platform,
{
imageUrl,
userId,
businessType: 'template_execution',
extraData: {
templateId: templateConfig.id,
templateCode: code,
},
}
);
// 2. 检查审核结果
if (auditResult.conclusion !== AuditConclusion.PASS) {
const failureReason = auditResult.details.length > 0
? auditResult.details.map(d => d.description).join(', ')
: '包含不当内容';
throw new HttpException(
`图片审核未通过: ${failureReason}`,
HttpStatus.FORBIDDEN,
);
}
// 3. 审核通过,执行模板
const template = await this.templateFactory.createTemplateByCode(code);
const taskId = await template.execute(imageUrl);
// 将任务保存到 TemplateExecutionEntity
const userId = req.user.userId;
// 4. 将任务保存到 TemplateExecutionEntity
const execution = this.executionRepository.create({
templateId: templateConfig.id,
userId,
@@ -167,13 +209,13 @@ export class TemplateController {
const savedExecution = await this.executionRepository.save(execution);
// 返回任务id (执行记录的ID)
// 返回任务id (执行记录的ID) 以及审核信息
return ResponseUtil.success(savedExecution.id, '模板执行已启动');
} catch (error) {
console.error(error)
throw new HttpException(
error.message || 'Template execution failed',
HttpStatus.INTERNAL_SERVER_ERROR,
error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner, Table, Index } from 'typeorm';
export class CreateContentAuditLogTable1725510000000 implements MigrationInterface {
name = 'CreateContentAuditLogTable1725510000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'content_audit_logs',
columns: [
{
name: 'id',
type: 'int',
isPrimary: true,
isGenerated: true,
generationStrategy: 'increment',
},
{
name: 'task_id',
type: 'varchar',
length: '255',
isUnique: true,
},
{
name: 'user_id',
type: 'varchar',
length: '255',
},
{
name: 'platform',
type: 'enum',
enum: [
'wechat',
'alipay',
'baidu',
'bytedance',
'jd',
'qq',
'feishu',
'kuaishou',
'h5',
'rn'
],
},
{
name: 'content_type',
type: 'varchar',
length: '50',
default: "'image'",
},
{
name: 'content_url',
type: 'varchar',
length: '500',
},
{
name: 'business_type',
type: 'varchar',
length: '100',
default: "'default'",
},
{
name: 'status',
type: 'enum',
enum: ['pending', 'processing', 'completed', 'failed', 'timeout'],
default: "'pending'",
},
{
name: 'conclusion',
type: 'enum',
enum: ['pass', 'reject', 'review', 'uncertain'],
isNullable: true,
},
{
name: 'confidence',
type: 'int',
default: 0,
},
{
name: 'risk_level',
type: 'enum',
enum: ['low', 'medium', 'high', 'critical'],
isNullable: true,
},
{
name: 'suggestion',
type: 'enum',
enum: ['pass', 'block', 'human_review', 'delete'],
isNullable: true,
},
{
name: 'input_params',
type: 'json',
isNullable: true,
},
{
name: 'audit_result',
type: 'json',
isNullable: true,
},
{
name: 'error_message',
type: 'text',
isNullable: true,
},
{
name: 'completed_at',
type: 'timestamp',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
},
],
}),
true,
);
// Note: Indexes are defined in the entity class and will be created automatically
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('content_audit_logs');
}
}