Files
bw-mini-app-server/src/controllers/template.controller.ts
imeepos 4cb6b63948 feat: 修复抖音图片审核失败并设计统一异步架构
解决抖音图片审核"无效图片URL"问题,并设计统一异步架构方案:

## 核心修复
- 增强 validateImageUrl 方法,支持文件扩展名 + Content-Type 双重验证
- 修复抖音审核失败问题:binary/octet-stream 响应头导致的验证失败

## 架构设计
- 设计统一异步内容审核架构,抹平平台差异(同步/异步)
- 创建 EnhancedBaseContentAdapter 统一接口
- 实现平台适配器模式,WeChat同步转异步,Douyin原生异步
- 添加回调驱动的模板执行流程

## 数据库增强
- 新增 taskId 字段关联审核任务和模板执行
- 添加 PENDING_AUDIT 状态支持异步审核流程
- 创建 MySQL 优化的数据库迁移脚本

## 文档完善
- 更新内容审核设计文档,包含详细序列图
- 创建 5 阶段升级文档,含 MySQL 专项优化
- 提供完整的向后兼容迁移方案

## 测试改进
- 更新单元测试,修复 validateStatus 参数
- 增强错误处理和边界情况测试覆盖
2025-09-05 16:08:45 +08:00

868 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
ParseIntPipe,
Query,
HttpException,
HttpStatus,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse as SwaggerApiResponse,
ApiParam,
ApiQuery,
ApiBody,
} from '@nestjs/swagger';
import { N8nTemplateFactoryService } from '../services/n8n-template-factory.service';
import {
N8nTemplateEntity,
TemplateType,
} from '../entities/n8n-template.entity';
import {
CreateTemplateDto,
UpdateTemplateDto,
TemplateExecuteDto,
TemplateExecuteResponseDto,
TemplateListDto,
BatchExecuteDto,
} from '../dto/template.dto';
import {
TemplateExecutionEntity,
ExecutionStatus,
ExecutionType,
} from '../entities/template-execution.entity';
import { InjectRepository } from '@nestjs/typeorm';
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')
@ApiCommonResponses()
export class TemplateController {
constructor(
private readonly templateFactory: N8nTemplateFactoryService,
@InjectRepository(N8nTemplateEntity)
private readonly templateRepository: Repository<N8nTemplateEntity>,
@InjectRepository(TemplateExecutionEntity)
private readonly executionRepository: Repository<TemplateExecutionEntity>,
private readonly unifiedContentService: UnifiedContentService,
) {}
@Post(':templateId/execute')
@ApiOperation({
summary: '执行模板生成',
description: '根据模板ID执行AI生成任务支持图片和视频生成',
})
@ApiParam({ name: 'templateId', description: '模板ID', example: 1 })
@ApiBody({ type: TemplateExecuteDto })
@SwaggerApiResponse({
status: 200,
description: '执行成功',
type: TemplateExecuteResponseDto,
})
@SwaggerApiResponse({
status: 400,
description: '参数错误',
schema: {
example: {
code: 400,
message: 'imageUrl is required',
data: null,
},
},
})
async executeTemplateById(
@Param('templateId', ParseIntPipe) templateId: number,
@Body() body: { imageUrl: string },
) {
try {
const { imageUrl } = body;
if (!imageUrl) {
throw new HttpException('imageUrl is required', HttpStatus.BAD_REQUEST);
}
// 通过工厂服务创建模板实例并执行
const template = await this.templateFactory.createTemplate(templateId);
const result = await template.execute(imageUrl);
return ResponseUtil.success(
{
templateId,
templateCode: template.code,
templateName: template.name,
inputUrl: imageUrl,
outputUrl: result,
creditCost: template.creditCost,
},
'执行成功',
);
} catch (error) {
throw new HttpException(
error.message || 'Template execution failed',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post('code/:code/execute')
@UseGuards(PlatformAuthGuard)
@ApiOperation({
summary: '通过代码执行模板',
description:
'根据模板代码执行AI生成任务支持图片和视频生成。执行前会进行图片内容审核。',
})
@ApiParam({
name: 'code',
description: '模板代码',
example: 'character_figurine_v1',
})
@ApiBody({ type: TemplateExecuteDto })
@SwaggerApiResponse({
status: 200,
description: '执行成功',
type: TemplateExecuteResponseDto,
})
@SwaggerApiResponse({
status: 403,
description: '图片审核未通过',
schema: {
example: {
code: 403,
message: '图片审核未通过: 包含不当内容',
data: null,
},
},
})
@SwaggerApiResponse({
status: 429,
description: '任务数量限制',
schema: {
example: {
code: 429,
message:
'当前账号有3个任务正在进行中且距开始时间不足5分钟请稍后再试',
data: null,
},
},
})
async executeTemplateByCode(
@Param('code') code: string,
@Body() body: { imageUrl: string },
@Request() req,
): Promise<ApiResponse<number>> {
try {
const { imageUrl } = body;
if (!imageUrl) {
throw new HttpException('imageUrl is required', HttpStatus.BAD_REQUEST);
}
const userId = req.user.userId;
// 检查用户当前的任务限制
await this.checkUserTaskLimit(userId);
// 首先获取模板配置以确定模板类型
const templateConfig = await this.templateFactory.getTemplateByCode(code);
if (!templateConfig) {
throw new HttpException('Template not found', HttpStatus.NOT_FOUND);
}
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);
// 4. 将任务保存到 TemplateExecutionEntity
const execution = this.executionRepository.create({
templateId: templateConfig.id,
userId,
platform: req.user.platform,
type:
templateConfig.templateType === TemplateType.VIDEO
? ExecutionType.VIDEO
: ExecutionType.IMAGE,
prompt: '', // 可以从请求参数中获取,如果有的话
inputImageUrl: imageUrl,
taskId: taskId, // 保存外部系统返回的任务ID用于回调匹配
status: ExecutionStatus.PROCESSING,
progress: 0,
creditCost: templateConfig.creditCost,
startedAt: new Date(),
});
const savedExecution = await this.executionRepository.save(execution);
// 返回任务id (执行记录的ID) 以及审核信息
return ResponseUtil.success(savedExecution.id, '模板执行已启动');
} catch (error) {
console.error(error);
throw new HttpException(
error.message || 'Template execution failed',
error instanceof HttpException
? error.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* 检查用户当前任务限制
* @param userId 用户ID
* @throws HttpException 如果超出任务限制
*/
private async checkUserTaskLimit(userId: string): Promise<void> {
// 查询用户当前进行中的任务
const processingTasks = await this.executionRepository.find({
where: {
userId,
status: ExecutionStatus.PROCESSING,
},
order: {
startedAt: 'ASC',
},
});
// 如果当前没有进行中的任务,允许执行
if (processingTasks.length === 0) {
return;
}
// 如果当前进行中任务数量已达到3个需要检查时间限制
if (processingTasks.length >= 3) {
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
// 检查是否有任务在5分钟内开始
const recentTasks = processingTasks.filter(
(task) =>
task.startedAt && task.startedAt.getTime() > fiveMinutesAgo.getTime(),
);
if (recentTasks.length > 3) {
throw new HttpException(
`${recentTasks.length}个任务正在进行中,请稍后再试`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
}
@Post(':templateId/batch-execute')
@ApiOperation({
summary: '批量执行模板',
description: '批量执行模板生成任务,支持多张图片同时处理',
})
@ApiParam({ name: 'templateId', description: '模板ID', example: 1 })
@ApiBody({ type: BatchExecuteDto })
@SwaggerApiResponse({
status: 200,
description: '批量执行成功',
schema: {
example: {
success: true,
data: {
templateId: 1,
totalCount: 2,
results: [
{ inputUrl: 'image1.jpg', outputUrl: 'output1.jpg' },
{ inputUrl: 'image2.jpg', outputUrl: 'output2.jpg' },
],
},
},
},
})
async batchExecuteTemplate(
@Param('templateId', ParseIntPipe) templateId: number,
@Body() body: { imageUrls: string[] },
) {
try {
const { imageUrls } = body;
if (!imageUrls || !Array.isArray(imageUrls) || imageUrls.length === 0) {
throw new HttpException(
'imageUrls array is required',
HttpStatus.BAD_REQUEST,
);
}
const results = await this.templateFactory.batchExecute(
templateId,
imageUrls,
);
return ResponseUtil.success(
{
templateId,
totalCount: imageUrls.length,
results: imageUrls.map((inputUrl, index) => ({
inputUrl,
outputUrl: results[index],
})),
},
'批量执行成功',
);
} catch (error) {
throw new HttpException(
error.message || 'Batch execution failed',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get()
@ApiOperation({
summary: '获取所有模板列表',
description: '获取所有可用的AI生成模板支持按类型筛选',
})
@SwaggerApiResponse({
status: 200,
description: '获取成功',
type: [TemplateListDto],
})
async getAllTemplates(): Promise<ApiResponse<any[]>> {
try {
const templates = await this.templateFactory.getAllTemplates();
const data = templates.map((template) => ({
id: template.id,
code: template.code,
name: template.name,
description: template.description,
creditCost: template.creditCost,
version: template.version,
templateType: template.templateType,
templateClass: template.templateClass,
tags: template.tags,
inputExample: template.inputExampleUrl,
outputExample: template.outputExampleUrl,
sortOrder: template.sortOrder,
isActive: template.isActive,
createdAt: template.createdAt,
updatedAt: template.updatedAt,
}));
return ResponseUtil.success(data, '获取模板列表成功');
} catch (error) {
throw new HttpException(
error.message || 'Failed to fetch templates',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('image')
@ApiOperation({
summary: '获取图片模板列表',
description: '获取所有图片类型的AI生成模板',
})
@SwaggerApiResponse({
status: 200,
description: '获取成功',
type: [TemplateListDto],
})
async getImageTemplates() {
try {
const templates = await this.templateFactory.getImageTemplates();
return ResponseUtil.success(templates, '获取图片模板列表成功');
} catch (error) {
throw new HttpException(
error.message || 'Failed to fetch image templates',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('video')
@ApiOperation({
summary: '获取视频模板列表',
description: '获取所有视频类型的AI生成模板',
})
@SwaggerApiResponse({
status: 200,
description: '获取成功',
type: [TemplateListDto],
})
async getVideoTemplates() {
try {
const templates = await this.templateFactory.getVideoTemplates();
return ResponseUtil.success(templates, '获取视频模板列表成功');
} catch (error) {
throw new HttpException(
error.message || 'Failed to fetch video templates',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get('recommend')
@ApiOperation({
summary: '推荐模板',
description: '根据用户偏好标签推荐适合的模板',
})
@ApiQuery({
name: 'tags',
required: false,
description: '用户偏好标签,逗号分隔',
example: '人物,手办',
})
@ApiQuery({
name: 'limit',
required: false,
description: '推荐数量',
example: 5,
})
@SwaggerApiResponse({
status: 200,
description: '推荐成功',
schema: {
example: {
success: true,
data: {
userTags: ['人物', '手办'],
recommendedTemplates: [],
},
},
},
})
async recommendTemplates(
@Query('tags') tags?: string,
@Query('limit') limit: number = 5,
) {
try {
const userTags = tags ? tags.split(',').map((tag) => tag.trim()) : [];
const templates = await this.templateFactory.recommendTemplates(
userTags,
limit,
);
return ResponseUtil.success(
{
userTags,
recommendedTemplates: templates,
},
'模板推荐成功',
);
} catch (error) {
throw new HttpException(
error.message || 'Failed to recommend templates',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Get(':templateId')
@ApiOperation({
summary: '获取模板详情',
description: '根据模板ID获取详细信息',
})
@ApiParam({ name: 'templateId', description: '模板ID', example: 1 })
@SwaggerApiResponse({
status: 200,
description: '获取成功',
type: TemplateListDto,
})
@SwaggerApiResponse({
status: 404,
description: '模板不存在',
})
async getTemplateById(@Param('templateId', ParseIntPipe) templateId: number) {
try {
const template = await this.templateFactory.createTemplate(templateId);
return ResponseUtil.success(
{
id: templateId,
code: template.code,
name: template.name,
description: template.description,
creditCost: template.creditCost,
version: template.version,
tags: template.tags,
inputExample: template.input,
outputExample: template.output,
},
'获取模板详情成功',
);
} catch (error) {
throw new HttpException(
error.message || 'Template not found',
HttpStatus.NOT_FOUND,
);
}
}
/**
* 查询模板执行进度
* @param taskId 任务ID
* @returns 执行进度信息
*/
@Get('execution/:taskId/progress')
async getExecutionProgress(
@Param('taskId', ParseIntPipe) taskId: number,
): Promise<ApiResponse<any>> {
try {
const execution = await this.executionRepository.findOne({
where: { id: taskId },
relations: ['template'],
});
if (!execution) {
throw new HttpException(
'Execution task not found',
HttpStatus.NOT_FOUND,
);
}
return ResponseUtil.success(
{
taskId: execution.id,
templateId: execution.templateId,
templateName: execution.template?.name,
userId: execution.userId,
platform: execution.platform,
type: execution.type,
status: execution.status,
progress: execution.progress,
inputImageUrl: execution.inputImageUrl,
outputUrl: execution.outputUrl,
thumbnailUrl: execution.thumbnailUrl,
errorMessage: execution.errorMessage,
creditCost: execution.creditCost,
startedAt: execution.startedAt,
completedAt: execution.completedAt,
executionDuration: execution.executionDuration,
createdAt: execution.createdAt,
updatedAt: execution.updatedAt,
executionResult: execution.executionResult,
},
'获取执行进度成功',
);
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Failed to fetch execution progress',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* 查询用户的执行任务列表
* @param userId 用户ID
* @param status 状态筛选
* @param limit 数量限制
* @returns 用户执行任务列表
*/
@Get('executions/user/:userId')
async getUserExecutions(
@Param('userId') userId: string,
@Query('status') status?: string,
@Query('limit') limit: number = 20,
) {
try {
const queryBuilder = this.executionRepository
.createQueryBuilder('execution')
.leftJoinAndSelect('execution.template', 'template')
.where('execution.userId = :userId', { userId })
.orderBy('execution.createdAt', 'DESC')
.limit(limit);
if (status) {
queryBuilder.andWhere('execution.status = :status', { status });
}
const executions = await queryBuilder.getMany();
return ResponseUtil.success(
executions.map((execution) => ({
taskId: execution.id,
templateId: execution.templateId,
templateName: execution.template?.name,
templateCode: execution.template?.code,
type: execution.type,
status: execution.status,
progress: execution.progress,
inputImageUrl: execution.inputImageUrl,
outputUrl: execution.outputUrl,
thumbnailUrl: execution.thumbnailUrl,
creditCost: execution.creditCost,
startedAt: execution.startedAt,
completedAt: execution.completedAt,
executionDuration: execution.executionDuration,
createdAt: execution.createdAt,
})),
'获取用户执行任务列表成功',
);
} catch (error) {
throw new HttpException(
error.message || 'Failed to fetch user executions',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
@Post()
@ApiOperation({
summary: '创建新模板',
description: '创建一个新的AI生成模板',
})
@ApiBody({ type: CreateTemplateDto })
@SwaggerApiResponse({
status: 200,
description: '创建成功',
schema: {
example: {
success: true,
data: {
id: 1,
code: 'new_template_v1',
name: '新模板',
},
},
},
})
@SwaggerApiResponse({
status: 409,
description: '模板代码已存在',
})
async createTemplate(@Body() createTemplateDto: CreateTemplateDto) {
try {
const existingTemplate = await this.templateRepository.findOne({
where: { code: createTemplateDto.code },
});
if (existingTemplate) {
throw new HttpException(
`Template with code '${createTemplateDto.code}' already exists`,
HttpStatus.CONFLICT,
);
}
const template = this.templateRepository.create(createTemplateDto);
const savedTemplate = await this.templateRepository.save(template);
return ResponseUtil.success(savedTemplate, '模板创建成功');
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Failed to create template',
HttpStatus.BAD_REQUEST,
);
}
}
@Put(':templateId')
@ApiOperation({
summary: '更新模板',
description: '更新指定模板的配置信息',
})
@ApiParam({ name: 'templateId', description: '模板ID', example: 1 })
@ApiBody({ type: UpdateTemplateDto })
@SwaggerApiResponse({
status: 200,
description: '更新成功',
type: TemplateListDto,
})
@SwaggerApiResponse({
status: 404,
description: '模板不存在',
})
@SwaggerApiResponse({
status: 409,
description: '模板代码已存在',
})
async updateTemplate(
@Param('templateId', ParseIntPipe) templateId: number,
@Body() updateTemplateDto: UpdateTemplateDto,
) {
try {
const existingTemplate = await this.templateRepository.findOne({
where: { id: templateId },
});
if (!existingTemplate) {
throw new HttpException('Template not found', HttpStatus.NOT_FOUND);
}
if (
updateTemplateDto.code &&
updateTemplateDto.code !== existingTemplate.code
) {
const codeConflict = await this.templateRepository.findOne({
where: { code: updateTemplateDto.code },
});
if (codeConflict) {
throw new HttpException(
`Template with code '${updateTemplateDto.code}' already exists`,
HttpStatus.CONFLICT,
);
}
}
await this.templateRepository.update(templateId, updateTemplateDto);
const updatedTemplate = await this.templateRepository.findOne({
where: { id: templateId },
});
return ResponseUtil.success(updatedTemplate, '模板更新成功');
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Failed to update template',
HttpStatus.BAD_REQUEST,
);
}
}
/**
* 删除模板
* @param templateId 模板ID
* @returns 删除结果
*/
@Delete(':templateId')
async deleteTemplate(@Param('templateId', ParseIntPipe) templateId: number) {
try {
const template = await this.templateRepository.findOne({
where: { id: templateId },
});
if (!template) {
throw new HttpException('Template not found', HttpStatus.NOT_FOUND);
}
await this.templateRepository.delete(templateId);
return ResponseUtil.success(null, '模板删除成功');
} catch (error) {
throw new HttpException(
error.message || 'Failed to delete template',
HttpStatus.BAD_REQUEST,
);
}
}
/**
* 获取模板详情(管理后台专用)
* @param templateId 模板ID
* @returns 模板完整详情(包含非激活状态的模板)
*/
@Get('admin/:templateId')
async getTemplateForAdmin(
@Param('templateId', ParseIntPipe) templateId: number,
) {
try {
const template = await this.templateRepository.findOne({
where: { id: templateId },
});
if (!template) {
throw new HttpException('Template not found', HttpStatus.NOT_FOUND);
}
return ResponseUtil.success(template, '获取模板详情成功');
} catch (error) {
throw new HttpException(
error.message || 'Template not found',
HttpStatus.NOT_FOUND,
);
}
}
/**
* 获取所有模板列表(管理后台专用)
* @param page 页码
* @param limit 每页数量
* @param type 模板类型筛选
* @param isActive 激活状态筛选
* @returns 分页的模板列表(包含非激活状态的模板)
*/
@Get('admin')
async getAllTemplatesForAdmin(
@Query('page') page: number = 1,
@Query('limit') limit: number = 10,
@Query('type') type?: TemplateType,
@Query('isActive') isActive?: boolean,
) {
try {
const queryBuilder =
this.templateRepository.createQueryBuilder('template');
if (type) {
queryBuilder.andWhere('template.templateType = :type', { type });
}
if (isActive !== undefined) {
queryBuilder.andWhere('template.isActive = :isActive', { isActive });
}
queryBuilder
.orderBy('template.sortOrder', 'DESC')
.addOrderBy('template.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [templates, total] = await queryBuilder.getManyAndCount();
return ResponseUtil.paginated(
templates,
total,
page,
limit,
'获取模板列表成功',
);
} catch (error) {
throw new HttpException(
error.message || 'Failed to fetch templates',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}