feat: 添加图片内容审核功能到模板执行流程
- 集成统一内容审核服务到模板控制器 - 在模板执行前进行图片内容审核 - 审核未通过时返回403错误和详细原因 - 添加内容审核模块和相关服务 - 创建内容审核日志表迁移文件
This commit is contained in:
@@ -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],
|
||||
|
||||
332
src/content-moderation/__tests__/douyin-content.adapter.spec.ts
Normal file
332
src/content-moderation/__tests__/douyin-content.adapter.spec.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
src/content-moderation/__tests__/unified-content.service.spec.ts
Normal file
267
src/content-moderation/__tests__/unified-content.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
src/content-moderation/adapters/base-content.adapter.ts
Normal file
118
src/content-moderation/adapters/base-content.adapter.ts
Normal 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>;
|
||||
}
|
||||
278
src/content-moderation/adapters/douyin-content.adapter.ts
Normal file
278
src/content-moderation/adapters/douyin-content.adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/content-moderation/adapters/index.ts
Normal file
3
src/content-moderation/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './base-content.adapter';
|
||||
export * from './douyin-content.adapter';
|
||||
export * from './wechat-content.adapter';
|
||||
274
src/content-moderation/adapters/wechat-content.adapter.ts
Normal file
274
src/content-moderation/adapters/wechat-content.adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/content-moderation/content-moderation.module.ts
Normal file
51
src/content-moderation/content-moderation.module.ts
Normal 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 {}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
92
src/content-moderation/dto/audit-response.dto.ts
Normal file
92
src/content-moderation/dto/audit-response.dto.ts
Normal 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;
|
||||
}
|
||||
43
src/content-moderation/dto/image-audit.dto.ts
Normal file
43
src/content-moderation/dto/image-audit.dto.ts
Normal 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;
|
||||
}
|
||||
77
src/content-moderation/entities/content-audit-log.entity.ts
Normal file
77
src/content-moderation/entities/content-audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
32
src/content-moderation/guards/content-audit.guard.ts
Normal file
32
src/content-moderation/guards/content-audit.guard.ts
Normal 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('内容审核状态异常');
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/content-moderation/interfaces/audit-result.interface.ts
Normal file
65
src/content-moderation/interfaces/audit-result.interface.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
51
src/content-moderation/services/content-adapter.factory.ts
Normal file
51
src/content-moderation/services/content-adapter.factory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
225
src/content-moderation/services/unified-content.service.ts
Normal file
225
src/content-moderation/services/unified-content.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
133
src/migrations/1725510000000-CreateContentAuditLogTable.ts
Normal file
133
src/migrations/1725510000000-CreateContentAuditLogTable.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user