diff --git a/src/app.module.ts b/src/app.module.ts index 01ceec4..30e29a3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/content-moderation/__tests__/douyin-content.adapter.spec.ts b/src/content-moderation/__tests__/douyin-content.adapter.spec.ts new file mode 100644 index 0000000..cae2301 --- /dev/null +++ b/src/content-moderation/__tests__/douyin-content.adapter.spec.ts @@ -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; + + 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); + httpService = module.get(HttpService); + configService = module.get(ConfigService); + repository = module.get>( + 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, + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/src/content-moderation/__tests__/unified-content.service.spec.ts b/src/content-moderation/__tests__/unified-content.service.spec.ts new file mode 100644 index 0000000..67e3dd4 --- /dev/null +++ b/src/content-moderation/__tests__/unified-content.service.spec.ts @@ -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; + + 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); + contentAdapterFactory = module.get(ContentAdapterFactory); + auditLogRepository = module.get>( + 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); + }); + }); +}); \ No newline at end of file diff --git a/src/content-moderation/adapters/base-content.adapter.ts b/src/content-moderation/adapters/base-content.adapter.ts new file mode 100644 index 0000000..78027a9 --- /dev/null +++ b/src/content-moderation/adapters/base-content.adapter.ts @@ -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, + ) {} + + /** + * 创建审核日志记录 + */ + async createAuditLog(auditData: ImageAuditRequest): Promise { + 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 { + 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 { + 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 { + 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; + abstract auditImageBatch(auditDataList: ImageAuditRequest[]): Promise; + abstract queryAuditResult(taskId: string): Promise; + abstract handleAuditCallback(callbackData: any): Promise; +} \ No newline at end of file diff --git a/src/content-moderation/adapters/douyin-content.adapter.ts b/src/content-moderation/adapters/douyin-content.adapter.ts new file mode 100644 index 0000000..178a38b --- /dev/null +++ b/src/content-moderation/adapters/douyin-content.adapter.ts @@ -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 { + 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 { + // 抖音批量审核实现 + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/src/content-moderation/adapters/index.ts b/src/content-moderation/adapters/index.ts new file mode 100644 index 0000000..88b561f --- /dev/null +++ b/src/content-moderation/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './base-content.adapter'; +export * from './douyin-content.adapter'; +export * from './wechat-content.adapter'; \ No newline at end of file diff --git a/src/content-moderation/adapters/wechat-content.adapter.ts b/src/content-moderation/adapters/wechat-content.adapter.ts new file mode 100644 index 0000000..e36763c --- /dev/null +++ b/src/content-moderation/adapters/wechat-content.adapter.ts @@ -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 { + 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 { + // 微信批量审核实现 + 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 { + 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 { + // 微信审核是同步的,不需要处理回调 + console.log('微信审核为同步模式,无需处理回调'); + } + + /** + * 调用微信审核API + */ + private async callWechatAuditAPI(auditData: ImageAuditRequest): Promise { + // 获取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 { + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/src/content-moderation/content-moderation.module.ts b/src/content-moderation/content-moderation.module.ts new file mode 100644 index 0000000..5b5b2cb --- /dev/null +++ b/src/content-moderation/content-moderation.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/content-moderation/controllers/content-moderation.controller.ts b/src/content-moderation/controllers/content-moderation.controller.ts new file mode 100644 index 0000000..eae87bf --- /dev/null +++ b/src/content-moderation/controllers/content-moderation.controller.ts @@ -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, + }, + }; + } +} \ No newline at end of file diff --git a/src/content-moderation/dto/audit-response.dto.ts b/src/content-moderation/dto/audit-response.dto.ts new file mode 100644 index 0000000..3678081 --- /dev/null +++ b/src/content-moderation/dto/audit-response.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/content-moderation/dto/image-audit.dto.ts b/src/content-moderation/dto/image-audit.dto.ts new file mode 100644 index 0000000..d7775f0 --- /dev/null +++ b/src/content-moderation/dto/image-audit.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/content-moderation/entities/content-audit-log.entity.ts b/src/content-moderation/entities/content-audit-log.entity.ts new file mode 100644 index 0000000..6f1b3d9 --- /dev/null +++ b/src/content-moderation/entities/content-audit-log.entity.ts @@ -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; +} \ No newline at end of file diff --git a/src/content-moderation/guards/content-audit.guard.ts b/src/content-moderation/guards/content-audit.guard.ts new file mode 100644 index 0000000..044ee07 --- /dev/null +++ b/src/content-moderation/guards/content-audit.guard.ts @@ -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 { + 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('内容审核状态异常'); + } + } +} \ No newline at end of file diff --git a/src/content-moderation/interfaces/audit-result.interface.ts b/src/content-moderation/interfaces/audit-result.interface.ts new file mode 100644 index 0000000..3e942c0 --- /dev/null +++ b/src/content-moderation/interfaces/audit-result.interface.ts @@ -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; +} \ No newline at end of file diff --git a/src/content-moderation/interfaces/content-moderation.interface.ts b/src/content-moderation/interfaces/content-moderation.interface.ts new file mode 100644 index 0000000..986505e --- /dev/null +++ b/src/content-moderation/interfaces/content-moderation.interface.ts @@ -0,0 +1,80 @@ +import { PlatformType } from '../../entities/platform-user.entity'; + +export interface IContentModerationAdapter { + platform: PlatformType; + + // 图片审核 + auditImage(auditData: ImageAuditRequest): Promise; + + // 批量图片审核 + auditImageBatch(auditDataList: ImageAuditRequest[]): Promise; + + // 查询审核结果 + queryAuditResult(taskId: string): Promise; + + // 异步审核回调处理 + handleAuditCallback(callbackData: any): Promise; +} + +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; + }; +} \ No newline at end of file diff --git a/src/content-moderation/services/content-adapter.factory.ts b/src/content-moderation/services/content-adapter.factory.ts new file mode 100644 index 0000000..15d3e45 --- /dev/null +++ b/src/content-moderation/services/content-adapter.factory.ts @@ -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(); + + 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); + } +} \ No newline at end of file diff --git a/src/content-moderation/services/unified-content.service.ts b/src/content-moderation/services/unified-content.service.ts new file mode 100644 index 0000000..324cbc8 --- /dev/null +++ b/src/content-moderation/services/unified-content.service.ts @@ -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, + ) {} + + /** + * 统一图片审核接口 + */ + async auditImage(platform: PlatformType, auditData: ImageAuditRequest): Promise { + const adapter = this.contentAdapterFactory.getAdapter(platform); + return adapter.auditImage(auditData); + } + + /** + * 批量图片审核 + */ + async auditImageBatch(platform: PlatformType, auditDataList: ImageAuditRequest[]): Promise { + const adapter = this.contentAdapterFactory.getAdapter(platform); + return adapter.auditImageBatch(auditDataList); + } + + /** + * 查询审核结果 + */ + async queryAuditResult(platform: PlatformType, taskId: string): Promise { + const adapter = this.contentAdapterFactory.getAdapter(platform); + return adapter.queryAuditResult(taskId); + } + + /** + * 处理审核回调 + */ + async handleAuditCallback(platform: PlatformType, callbackData: any): Promise { + const adapter = this.contentAdapterFactory.getAdapter(platform); + return adapter.handleAuditCallback(callbackData); + } + + /** + * 获取用户审核历史 + */ + async getUserAuditHistory(userId: string, limit = 50): Promise { + return this.auditLogRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + /** + * 获取审核统计 + */ + async getAuditStats(platform?: PlatformType, startDate?: Date, endDate?: Date): Promise { + 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 { + const auditLog = await this.auditLogRepository.findOne({ + where: { taskId } + }); + + if (!auditLog) { + throw new BadRequestException('审核记录不存在'); + } + + return auditLog.conclusion === 'pass'; + } + + /** + * 获取审核详细统计数据 + */ + async getDetailedAuditStats(platform?: PlatformType, days = 30): Promise { + 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 { + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/src/controllers/template.controller.ts b/src/controllers/template.controller.ts index aa21a00..f7ee689 100644 --- a/src/controllers/template.controller.ts +++ b/src/controllers/template.controller.ts @@ -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, @InjectRepository(TemplateExecutionEntity) private readonly executionRepository: Repository, + 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, ); } } diff --git a/src/migrations/1725510000000-CreateContentAuditLogTable.ts b/src/migrations/1725510000000-CreateContentAuditLogTable.ts new file mode 100644 index 0000000..e9f55c0 --- /dev/null +++ b/src/migrations/1725510000000-CreateContentAuditLogTable.ts @@ -0,0 +1,133 @@ +import { MigrationInterface, QueryRunner, Table, Index } from 'typeorm'; + +export class CreateContentAuditLogTable1725510000000 implements MigrationInterface { + name = 'CreateContentAuditLogTable1725510000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable('content_audit_logs'); + } +} \ No newline at end of file