一、超管端设计优化 - 文档管理SOP体系建立,docs目录重组 - 统一用户管理:跨租户全局视角,合并用户管理+公众用户 - 活动监管全模块重构:全部活动(统计卡片+阶段筛选+SuperDetail详情页)、报名数据/作品数据/评审进度(两层合一扁平列表)、成果发布(去Tab+统计+隐藏写操作) - 菜单精简:移除评委管理/评审规则/通知管理 - Bug修复:租户编辑丢失隐藏菜单、pageSize限制、主色统一 二、UGC绘本创作社区P0 - 数据库:10张新表(user_works/user_work_pages/work_tags等) - 子女账号独立化:Child升级为独立User,家长切换+独立登录 - 用户作品库:CRUD+发布审核,8个API - AI创作流程:提交→生成→保存到作品库,4个API - 作品广场:首页改造为推荐流,标签+搜索+排序 - 内容审核(超管端):作品审核+作品管理+标签管理 - 活动联动:WorkSelector作品选择器 - 布局改造:底部5Tab(发现/创作/活动/作品库/我的) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1045 lines
28 KiB
Markdown
1045 lines
28 KiB
Markdown
# AI 3D 模型生成功能开发文档
|
||
|
||
## 1. 功能概述
|
||
|
||
### 1.1 功能描述
|
||
用户可以通过文字描述或上传图片,利用 AI 技术生成 3D 模型(GLB/GLTF 格式)。类似腾讯混元 3D (https://3d.hunyuan.tencent.com/) 的功能。
|
||
|
||
### 1.2 核心特性
|
||
- **文字生成 3D**:用户输入一段文字描述,AI 生成对应的 3D 模型
|
||
- **图片生成 3D**:用户上传一张图片,AI 根据图片生成 3D 模型
|
||
- **任务历史**:保存用户的生成历史记录,支持查看和删除
|
||
- **3D 预览**:生成完成后可在线预览 3D 模型
|
||
|
||
### 1.3 技术选型
|
||
- **开发阶段**:使用 Mock 数据模拟 AI 生成过程
|
||
- **生产阶段**:可对接腾讯混元 3D API 或 Meshy AI API
|
||
- **入口方式**:独立页面 `/ai-3d`
|
||
|
||
---
|
||
|
||
## 2. 数据库设计
|
||
|
||
### 2.1 AI3DTask 表结构
|
||
|
||
```prisma
|
||
model AI3DTask {
|
||
id Int @id @default(autoincrement())
|
||
tenantId Int @map("tenant_id")
|
||
userId Int @map("user_id")
|
||
inputType String @map("input_type") // text | image
|
||
inputContent String @db.Text @map("input_content") // 文字描述或图片URL
|
||
status String @default("pending") // pending | processing | completed | failed | timeout
|
||
resultUrl String? @map("result_url") // 生成的3D模型URL
|
||
previewUrl String? @map("preview_url") // 预览图URL
|
||
errorMessage String? @map("error_message") // 失败时的错误信息
|
||
externalTaskId String? @map("external_task_id") // 外部AI服务的任务ID
|
||
retryCount Int @default(0) @map("retry_count") // 已重试次数
|
||
createTime DateTime @default(now()) @map("create_time")
|
||
completeTime DateTime? @map("complete_time")
|
||
|
||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||
user User @relation(fields: [userId], references: [id])
|
||
|
||
@@index([userId])
|
||
@@index([tenantId])
|
||
@@index([status])
|
||
@@index([createTime])
|
||
@@map("t_ai_3d_task")
|
||
}
|
||
```
|
||
|
||
### 2.2 字段说明
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| id | Int | 主键,自增 |
|
||
| tenantId | Int | 租户ID,用于多租户隔离 |
|
||
| userId | Int | 用户ID,任务归属用户 |
|
||
| inputType | String | 输入类型:`text`(文字) 或 `image`(图片) |
|
||
| inputContent | Text | 输入内容:文字描述或图片URL |
|
||
| status | String | 任务状态:pending/processing/completed/failed/timeout |
|
||
| resultUrl | String | 生成的3D模型文件URL |
|
||
| previewUrl | String | 模型预览图URL |
|
||
| errorMessage | String | 失败时的错误信息 |
|
||
| externalTaskId | String | 外部AI服务的任务ID,用于查询状态 |
|
||
| retryCount | Int | 已重试次数,默认0 |
|
||
| createTime | DateTime | 创建时间 |
|
||
| completeTime | DateTime | 完成时间 |
|
||
|
||
### 2.3 状态流转
|
||
|
||
```
|
||
pending (待处理)
|
||
↓
|
||
processing (处理中)
|
||
↓
|
||
completed (已完成) / failed (失败) / timeout (超时)
|
||
↓
|
||
[用户重试] → pending
|
||
```
|
||
|
||
### 2.4 配置常量
|
||
|
||
| 常量 | 默认值 | 说明 |
|
||
|------|--------|------|
|
||
| MAX_CONCURRENT_TASKS | 3 | 每用户最大并行任务数 |
|
||
| TASK_TIMEOUT_MINUTES | 10 | 任务超时时间(分钟) |
|
||
| MAX_RETRY_COUNT | 3 | 最大重试次数 |
|
||
| CLEANUP_INTERVAL_MINUTES | 5 | 超时清理任务执行间隔 |
|
||
| OLD_TASK_RETENTION_DAYS | 30 | 失败任务保留天数 |
|
||
|
||
---
|
||
|
||
## 3. API 接口设计
|
||
|
||
### 3.1 创建生成任务
|
||
|
||
**POST** `/api/ai-3d/generate`
|
||
|
||
**Request Body:**
|
||
```json
|
||
{
|
||
"inputType": "text", // "text" | "image"
|
||
"inputContent": "一只可爱的小猫" // 文字描述或图片URL
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"id": 1,
|
||
"inputType": "text",
|
||
"inputContent": "一只可爱的小猫",
|
||
"status": "pending",
|
||
"createTime": "2024-01-13T10:00:00.000Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.2 获取任务列表
|
||
|
||
**GET** `/api/ai-3d/tasks`
|
||
|
||
**Query Parameters:**
|
||
| 参数 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| page | number | 否 | 页码,默认1 |
|
||
| pageSize | number | 否 | 每页数量,默认10 |
|
||
| status | string | 否 | 状态筛选 |
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"list": [
|
||
{
|
||
"id": 1,
|
||
"inputType": "text",
|
||
"inputContent": "一只可爱的小猫",
|
||
"status": "completed",
|
||
"resultUrl": "/uploads/ai-3d/xxx.glb",
|
||
"previewUrl": "/uploads/ai-3d/xxx-preview.png",
|
||
"createTime": "2024-01-13T10:00:00.000Z",
|
||
"completeTime": "2024-01-13T10:01:30.000Z"
|
||
}
|
||
],
|
||
"total": 1,
|
||
"page": 1,
|
||
"pageSize": 10
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.3 获取任务详情
|
||
|
||
**GET** `/api/ai-3d/tasks/:id`
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"id": 1,
|
||
"inputType": "text",
|
||
"inputContent": "一只可爱的小猫",
|
||
"status": "completed",
|
||
"resultUrl": "/uploads/ai-3d/xxx.glb",
|
||
"previewUrl": "/uploads/ai-3d/xxx-preview.png",
|
||
"errorMessage": null,
|
||
"createTime": "2024-01-13T10:00:00.000Z",
|
||
"completeTime": "2024-01-13T10:01:30.000Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 删除任务
|
||
|
||
**DELETE** `/api/ai-3d/tasks/:id`
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": null
|
||
}
|
||
```
|
||
|
||
### 3.5 重试任务
|
||
|
||
**POST** `/api/ai-3d/tasks/:id/retry`
|
||
|
||
**说明:** 仅支持状态为 `failed` 或 `timeout` 的任务,每个任务最多重试 3 次
|
||
|
||
**Response (成功):**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"id": 1,
|
||
"inputType": "text",
|
||
"inputContent": "一只可爱的小猫",
|
||
"status": "processing",
|
||
"retryCount": 1,
|
||
"createTime": "2024-01-13T10:00:00.000Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Response (失败 - 达到重试上限):**
|
||
```json
|
||
{
|
||
"code": 400,
|
||
"message": "已达到最大重试次数 3 次,请创建新任务"
|
||
}
|
||
```
|
||
|
||
**Response (失败 - 并发限制):**
|
||
```json
|
||
{
|
||
"code": 400,
|
||
"message": "您当前有 3 个任务正在处理中,请等待完成后再重试"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 后端模块设计
|
||
|
||
### 4.1 目录结构
|
||
|
||
```
|
||
backend/src/ai-3d/
|
||
├── ai-3d.module.ts # 模块定义
|
||
├── ai-3d.controller.ts # 控制器
|
||
├── ai-3d.service.ts # 业务服务
|
||
├── ai-3d-cleanup.service.ts # 超时清理定时任务
|
||
├── dto/
|
||
│ ├── create-task.dto.ts # 创建任务DTO
|
||
│ └── query-task.dto.ts # 查询任务DTO
|
||
└── providers/
|
||
├── ai-3d-provider.interface.ts # AI服务接口
|
||
├── mock.provider.ts # Mock实现
|
||
├── hunyuan.provider.ts # 腾讯混元实现(预留)
|
||
└── meshy.provider.ts # Meshy AI实现(预留)
|
||
```
|
||
|
||
### 4.2 AI Provider 接口
|
||
|
||
```typescript
|
||
// ai-3d-provider.interface.ts
|
||
export interface AI3DGenerateResult {
|
||
taskId: string; // 外部任务ID
|
||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||
resultUrl?: string; // 3D模型URL
|
||
previewUrl?: string; // 预览图URL
|
||
errorMessage?: string; // 错误信息
|
||
}
|
||
|
||
export interface AI3DProvider {
|
||
/**
|
||
* 提交生成任务
|
||
*/
|
||
submitTask(inputType: 'text' | 'image', inputContent: string): Promise<string>;
|
||
|
||
/**
|
||
* 查询任务状态
|
||
*/
|
||
queryTask(taskId: string): Promise<AI3DGenerateResult>;
|
||
}
|
||
```
|
||
|
||
### 4.3 Mock Provider 实现逻辑
|
||
|
||
```typescript
|
||
// mock.provider.ts
|
||
@Injectable()
|
||
export class MockAI3DProvider implements AI3DProvider {
|
||
private tasks = new Map<string, MockTask>();
|
||
|
||
async submitTask(inputType: string, inputContent: string): Promise<string> {
|
||
const taskId = generateUUID();
|
||
this.tasks.set(taskId, {
|
||
status: 'processing',
|
||
startTime: Date.now(),
|
||
});
|
||
|
||
// 模拟5-10秒后完成
|
||
setTimeout(() => {
|
||
this.tasks.set(taskId, {
|
||
status: 'completed',
|
||
resultUrl: '/mock/sample-model.glb',
|
||
previewUrl: '/mock/sample-preview.png',
|
||
});
|
||
}, 5000 + Math.random() * 5000);
|
||
|
||
return taskId;
|
||
}
|
||
|
||
async queryTask(taskId: string): Promise<AI3DGenerateResult> {
|
||
const task = this.tasks.get(taskId);
|
||
if (!task) {
|
||
throw new NotFoundException('Task not found');
|
||
}
|
||
return task;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.4 Service 核心逻辑
|
||
|
||
```typescript
|
||
// ai-3d.service.ts
|
||
@Injectable()
|
||
export class AI3DService {
|
||
constructor(
|
||
private prisma: PrismaService,
|
||
private ai3dProvider: AI3DProvider,
|
||
) {}
|
||
|
||
async createTask(userId: number, tenantId: number, dto: CreateTaskDto) {
|
||
// 1. 创建数据库记录
|
||
const task = await this.prisma.aI3DTask.create({
|
||
data: {
|
||
userId,
|
||
tenantId,
|
||
inputType: dto.inputType,
|
||
inputContent: dto.inputContent,
|
||
status: 'pending',
|
||
},
|
||
});
|
||
|
||
// 2. 提交到AI服务
|
||
const externalTaskId = await this.ai3dProvider.submitTask(
|
||
dto.inputType,
|
||
dto.inputContent,
|
||
);
|
||
|
||
// 3. 更新状态为处理中
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: task.id },
|
||
data: { status: 'processing' },
|
||
});
|
||
|
||
// 4. 启动轮询检查任务状态(或使用消息队列)
|
||
this.pollTaskStatus(task.id, externalTaskId);
|
||
|
||
return task;
|
||
}
|
||
|
||
async getTasks(userId: number, query: QueryTaskDto) {
|
||
const { page = 1, pageSize = 10, status } = query;
|
||
|
||
const where = {
|
||
userId,
|
||
...(status && { status }),
|
||
};
|
||
|
||
const [list, total] = await Promise.all([
|
||
this.prisma.aI3DTask.findMany({
|
||
where,
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
orderBy: { createTime: 'desc' },
|
||
}),
|
||
this.prisma.aI3DTask.count({ where }),
|
||
]);
|
||
|
||
return { list, total, page, pageSize };
|
||
}
|
||
|
||
async getTask(userId: number, id: number) {
|
||
const task = await this.prisma.aI3DTask.findFirst({
|
||
where: { id, userId },
|
||
});
|
||
|
||
if (!task) {
|
||
throw new NotFoundException('任务不存在');
|
||
}
|
||
|
||
return task;
|
||
}
|
||
|
||
async deleteTask(userId: number, id: number) {
|
||
const task = await this.getTask(userId, id);
|
||
|
||
await this.prisma.aI3DTask.delete({
|
||
where: { id: task.id },
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
private async pollTaskStatus(taskId: number, externalTaskId: string) {
|
||
// 轮询检查任务状态,完成后更新数据库
|
||
const checkStatus = async () => {
|
||
try {
|
||
const result = await this.ai3dProvider.queryTask(externalTaskId);
|
||
|
||
if (result.status === 'completed' || result.status === 'failed') {
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: taskId },
|
||
data: {
|
||
status: result.status,
|
||
resultUrl: result.resultUrl,
|
||
previewUrl: result.previewUrl,
|
||
errorMessage: result.errorMessage,
|
||
completeTime: new Date(),
|
||
},
|
||
});
|
||
} else {
|
||
// 继续轮询
|
||
setTimeout(checkStatus, 2000);
|
||
}
|
||
} catch (error) {
|
||
console.error('Poll task status error:', error);
|
||
setTimeout(checkStatus, 5000);
|
||
}
|
||
};
|
||
|
||
setTimeout(checkStatus, 2000);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 前端页面设计
|
||
|
||
### 5.1 目录结构
|
||
|
||
```
|
||
frontend/src/
|
||
├── views/
|
||
│ └── ai-3d/
|
||
│ ├── Index.vue # 主页面
|
||
│ └── components/
|
||
│ ├── GenerateForm.vue # 生成表单
|
||
│ ├── TaskList.vue # 任务列表
|
||
│ └── TaskCard.vue # 任务卡片
|
||
├── api/
|
||
│ └── ai-3d.ts # API接口
|
||
└── router/
|
||
└── index.ts # 添加路由
|
||
```
|
||
|
||
### 5.2 页面布局
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ AI 3D 模型生成 │
|
||
├─────────────────────────────────────────────────────┤
|
||
│ ┌─────────────────────────────────────────────┐ │
|
||
│ │ 生成方式: ○ 文字描述 ○ 图片上传 │ │
|
||
│ │ │ │
|
||
│ │ [输入框/上传区域] │ │
|
||
│ │ │ │
|
||
│ │ [开始生成] │ │
|
||
│ └─────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 生成历史 │
|
||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||
│ │ 预览图 │ │ 预览图 │ │ 预览图 │ │ 预览图 │ │
|
||
│ │ │ │ │ │ │ │ │ │
|
||
│ │ 已完成 │ │ 处理中 │ │ 已完成 │ │ 失败 │ │
|
||
│ │ [查看] │ │ [...] │ │ [查看] │ │ [重试] │ │
|
||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||
│ │
|
||
│ [加载更多] │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 5.3 API 接口封装
|
||
|
||
```typescript
|
||
// api/ai-3d.ts
|
||
import request from '@/utils/request'
|
||
|
||
export interface CreateTaskParams {
|
||
inputType: 'text' | 'image'
|
||
inputContent: string
|
||
}
|
||
|
||
export interface QueryTaskParams {
|
||
page?: number
|
||
pageSize?: number
|
||
status?: string
|
||
}
|
||
|
||
export interface AI3DTask {
|
||
id: number
|
||
inputType: 'text' | 'image'
|
||
inputContent: string
|
||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'timeout'
|
||
resultUrl?: string
|
||
previewUrl?: string
|
||
errorMessage?: string
|
||
retryCount: number
|
||
createTime: string
|
||
completeTime?: string
|
||
}
|
||
|
||
// 创建生成任务
|
||
export function createAI3DTask(data: CreateTaskParams) {
|
||
return request.post<AI3DTask>('/ai-3d/generate', data)
|
||
}
|
||
|
||
// 获取任务列表
|
||
export function getAI3DTasks(params: QueryTaskParams) {
|
||
return request.get<{ list: AI3DTask[]; total: number }>('/ai-3d/tasks', { params })
|
||
}
|
||
|
||
// 获取任务详情
|
||
export function getAI3DTask(id: number) {
|
||
return request.get<AI3DTask>(`/ai-3d/tasks/${id}`)
|
||
}
|
||
|
||
// 删除任务
|
||
export function deleteAI3DTask(id: number) {
|
||
return request.delete(`/ai-3d/tasks/${id}`)
|
||
}
|
||
|
||
// 重试任务
|
||
export function retryAI3DTask(id: number) {
|
||
return request.post<AI3DTask>(`/ai-3d/tasks/${id}/retry`)
|
||
}
|
||
```
|
||
|
||
### 5.4 路由配置
|
||
|
||
```typescript
|
||
// router/index.ts - 添加到 baseRoutes 的 Main children 中
|
||
{
|
||
path: "ai-3d",
|
||
name: "AI3DGenerate",
|
||
component: () => import("@/views/ai-3d/Index.vue"),
|
||
meta: {
|
||
title: "AI 3D生成",
|
||
requiresAuth: true,
|
||
},
|
||
},
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 开发步骤
|
||
|
||
### 第一阶段:数据库与后端基础
|
||
|
||
1. **添加 Prisma Schema**
|
||
- 在 `schema.prisma` 中添加 `AI3DTask` 模型
|
||
- 在 `Tenant` 和 `User` 模型中添加关联
|
||
|
||
2. **运行数据库迁移**
|
||
```bash
|
||
cd backend
|
||
npx prisma migrate dev --name add_ai_3d_task
|
||
```
|
||
|
||
3. **创建后端模块**
|
||
- 创建 `ai-3d` 目录结构
|
||
- 实现 Mock Provider
|
||
- 实现 Service 和 Controller
|
||
- 注册模块到 AppModule
|
||
|
||
### 第二阶段:前端页面
|
||
|
||
4. **创建 API 接口文件**
|
||
- 创建 `src/api/ai-3d.ts`
|
||
|
||
5. **创建页面组件**
|
||
- 创建 `src/views/ai-3d/Index.vue` 主页面
|
||
- 实现生成表单
|
||
- 实现任务列表展示
|
||
|
||
6. **配置路由**
|
||
- 在 `router/index.ts` 中添加路由
|
||
|
||
### 第三阶段:功能完善
|
||
|
||
7. **状态轮询**
|
||
- 实现前端任务状态轮询
|
||
- 任务完成时自动刷新
|
||
|
||
8. **3D 预览集成**
|
||
- 复用现有的 ModelViewer 组件
|
||
|
||
9. **测试与优化**
|
||
- 测试完整流程
|
||
- 优化用户体验
|
||
|
||
---
|
||
|
||
## 7. 后续扩展
|
||
|
||
### 7.1 对接真实 AI 服务
|
||
|
||
当需要对接真实 AI 服务时,只需:
|
||
|
||
1. 实现对应的 Provider(如 `HunyuanProvider`)
|
||
2. 通过环境变量切换 Provider
|
||
3. 无需修改 Service 层代码
|
||
|
||
```typescript
|
||
// 通过环境变量选择 Provider
|
||
const provider = process.env.AI_3D_PROVIDER || 'mock';
|
||
|
||
switch (provider) {
|
||
case 'hunyuan':
|
||
return new HunyuanProvider(config);
|
||
case 'meshy':
|
||
return new MeshyProvider(config);
|
||
default:
|
||
return new MockProvider();
|
||
}
|
||
```
|
||
|
||
### 7.2 腾讯混元 3D API 参考
|
||
|
||
- 官网:https://3d.hunyuan.tencent.com/
|
||
- 支持文字生成3D、图片生成3D
|
||
- 需要腾讯云账号和 API 密钥
|
||
|
||
### 7.3 Meshy AI API 参考
|
||
|
||
- 官网:https://www.meshy.ai/
|
||
- 提供 REST API
|
||
- 支持 text-to-3d、image-to-3d
|
||
- 文档:https://docs.meshy.ai/
|
||
|
||
---
|
||
|
||
## 8. 并发控制与任务超时
|
||
|
||
### 8.1 并发控制
|
||
|
||
#### 8.1.1 设计目标
|
||
- 限制每个用户同时进行的任务数量,避免资源滥用
|
||
- 保证系统稳定性,防止单用户占用过多资源
|
||
- 提供友好的错误提示
|
||
|
||
#### 8.1.2 实现方案
|
||
|
||
```typescript
|
||
// ai-3d.service.ts
|
||
import { BadRequestException } from '@nestjs/common';
|
||
|
||
// 配置常量
|
||
const MAX_CONCURRENT_TASKS = 3; // 每用户最大并行任务数
|
||
|
||
@Injectable()
|
||
export class AI3DService {
|
||
async createTask(userId: number, tenantId: number, dto: CreateTaskDto) {
|
||
// 1. 检查用户当前进行中的任务数量
|
||
const activeTaskCount = await this.prisma.aI3DTask.count({
|
||
where: {
|
||
userId,
|
||
status: { in: ['pending', 'processing'] },
|
||
},
|
||
});
|
||
|
||
if (activeTaskCount >= MAX_CONCURRENT_TASKS) {
|
||
throw new BadRequestException(
|
||
`您当前有 ${activeTaskCount} 个任务正在处理中,最多同时处理 ${MAX_CONCURRENT_TASKS} 个任务,请等待完成后再提交`
|
||
);
|
||
}
|
||
|
||
// 2. 创建任务...
|
||
const task = await this.prisma.aI3DTask.create({
|
||
data: {
|
||
userId,
|
||
tenantId,
|
||
inputType: dto.inputType,
|
||
inputContent: dto.inputContent,
|
||
status: 'pending',
|
||
},
|
||
});
|
||
|
||
// 3. 提交到AI服务并更新状态
|
||
try {
|
||
const externalTaskId = await this.ai3dProvider.submitTask(
|
||
dto.inputType,
|
||
dto.inputContent,
|
||
);
|
||
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: task.id },
|
||
data: {
|
||
status: 'processing',
|
||
externalTaskId,
|
||
},
|
||
});
|
||
|
||
// 4. 启动轮询检查任务状态
|
||
this.pollTaskStatus(task.id, externalTaskId, Date.now());
|
||
|
||
return task;
|
||
} catch (error) {
|
||
// 提交失败,更新状态
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: task.id },
|
||
data: {
|
||
status: 'failed',
|
||
errorMessage: error.message || 'AI服务提交失败',
|
||
completeTime: new Date(),
|
||
},
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 8.1.3 前端处理
|
||
|
||
```typescript
|
||
// 前端提交任务时处理并发限制错误
|
||
const handleGenerate = async () => {
|
||
try {
|
||
loading.value = true;
|
||
await createAI3DTask({
|
||
inputType: inputType.value,
|
||
inputContent: inputContent.value,
|
||
});
|
||
message.success('任务已提交');
|
||
fetchTasks(); // 刷新列表
|
||
} catch (error: any) {
|
||
if (error.response?.status === 400) {
|
||
// 并发限制错误,显示友好提示
|
||
message.warning(error.response.data.message);
|
||
} else {
|
||
message.error('提交失败,请重试');
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### 8.2 任务超时处理
|
||
|
||
#### 8.2.1 设计目标
|
||
- 防止任务长时间卡在处理中状态
|
||
- 自动标记超时任务,释放用户并发配额
|
||
- 支持用户重试超时任务
|
||
|
||
#### 8.2.2 方案A:轮询时检查超时(适用于 Mock 开发阶段)
|
||
|
||
```typescript
|
||
// ai-3d.service.ts
|
||
const TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10分钟超时
|
||
|
||
private async pollTaskStatus(
|
||
taskId: number,
|
||
externalTaskId: string,
|
||
startTime: number
|
||
) {
|
||
const checkStatus = async () => {
|
||
// 1. 检查是否超时
|
||
if (Date.now() - startTime > TASK_TIMEOUT_MS) {
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: taskId },
|
||
data: {
|
||
status: 'timeout',
|
||
errorMessage: '任务处理超时,请重试',
|
||
completeTime: new Date(),
|
||
},
|
||
});
|
||
console.log(`Task ${taskId} timeout after ${TASK_TIMEOUT_MS}ms`);
|
||
return; // 停止轮询
|
||
}
|
||
|
||
// 2. 查询外部任务状态
|
||
try {
|
||
const result = await this.ai3dProvider.queryTask(externalTaskId);
|
||
|
||
if (result.status === 'completed' || result.status === 'failed') {
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: taskId },
|
||
data: {
|
||
status: result.status,
|
||
resultUrl: result.resultUrl,
|
||
previewUrl: result.previewUrl,
|
||
errorMessage: result.errorMessage,
|
||
completeTime: new Date(),
|
||
},
|
||
});
|
||
} else {
|
||
// 继续轮询,每2秒检查一次
|
||
setTimeout(checkStatus, 2000);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Poll task ${taskId} error:`, error);
|
||
// 出错后延长轮询间隔,每5秒重试
|
||
setTimeout(checkStatus, 5000);
|
||
}
|
||
};
|
||
|
||
// 首次检查延迟2秒
|
||
setTimeout(checkStatus, 2000);
|
||
}
|
||
```
|
||
|
||
#### 8.2.3 方案B:定时任务批量清理(推荐生产环境)
|
||
|
||
```typescript
|
||
// ai-3d-cleanup.service.ts
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
|
||
@Injectable()
|
||
export class AI3DCleanupService {
|
||
private readonly logger = new Logger(AI3DCleanupService.name);
|
||
|
||
constructor(private prisma: PrismaService) {}
|
||
|
||
/**
|
||
* 每5分钟检查并处理超时任务
|
||
*/
|
||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||
async handleTimeoutTasks() {
|
||
const TIMEOUT_MINUTES = 10;
|
||
const timeoutThreshold = new Date(Date.now() - TIMEOUT_MINUTES * 60 * 1000);
|
||
|
||
const result = await this.prisma.aI3DTask.updateMany({
|
||
where: {
|
||
status: { in: ['pending', 'processing'] },
|
||
createTime: { lt: timeoutThreshold },
|
||
},
|
||
data: {
|
||
status: 'timeout',
|
||
errorMessage: '任务处理超时,请重试',
|
||
completeTime: new Date(),
|
||
},
|
||
});
|
||
|
||
if (result.count > 0) {
|
||
this.logger.log(`清理了 ${result.count} 个超时任务`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每天凌晨2点清理30天前的失败/超时任务记录
|
||
*/
|
||
@Cron('0 2 * * *')
|
||
async cleanupOldTasks() {
|
||
const RETENTION_DAYS = 30;
|
||
const retentionThreshold = new Date(
|
||
Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||
);
|
||
|
||
const result = await this.prisma.aI3DTask.deleteMany({
|
||
where: {
|
||
status: { in: ['failed', 'timeout'] },
|
||
createTime: { lt: retentionThreshold },
|
||
},
|
||
});
|
||
|
||
if (result.count > 0) {
|
||
this.logger.log(`清理了 ${result.count} 个过期失败任务`);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 8.2.4 模块注册
|
||
|
||
```typescript
|
||
// ai-3d.module.ts
|
||
import { Module } from '@nestjs/common';
|
||
import { ScheduleModule } from '@nestjs/schedule';
|
||
import { AI3DController } from './ai-3d.controller';
|
||
import { AI3DService } from './ai-3d.service';
|
||
import { AI3DCleanupService } from './ai-3d-cleanup.service';
|
||
import { MockAI3DProvider } from './providers/mock.provider';
|
||
|
||
@Module({
|
||
imports: [ScheduleModule.forRoot()],
|
||
controllers: [AI3DController],
|
||
providers: [
|
||
AI3DService,
|
||
AI3DCleanupService,
|
||
{
|
||
provide: 'AI3D_PROVIDER',
|
||
useClass: MockAI3DProvider,
|
||
},
|
||
],
|
||
})
|
||
export class AI3DModule {}
|
||
```
|
||
|
||
---
|
||
|
||
### 8.3 重试机制
|
||
|
||
#### 8.3.1 重试API
|
||
|
||
**POST** `/api/ai-3d/tasks/:id/retry`
|
||
|
||
```typescript
|
||
// ai-3d.controller.ts
|
||
@Post('tasks/:id/retry')
|
||
async retryTask(@Param('id') id: number, @Request() req) {
|
||
return this.ai3dService.retryTask(req.user.id, id);
|
||
}
|
||
```
|
||
|
||
#### 8.3.2 重试逻辑
|
||
|
||
```typescript
|
||
// ai-3d.service.ts
|
||
const MAX_RETRY_COUNT = 3;
|
||
|
||
async retryTask(userId: number, taskId: number) {
|
||
const task = await this.prisma.aI3DTask.findFirst({
|
||
where: { id: taskId, userId },
|
||
});
|
||
|
||
if (!task) {
|
||
throw new NotFoundException('任务不存在');
|
||
}
|
||
|
||
// 只有失败或超时的任务可以重试
|
||
if (!['failed', 'timeout'].includes(task.status)) {
|
||
throw new BadRequestException('只有失败或超时的任务可以重试');
|
||
}
|
||
|
||
// 检查重试次数
|
||
if (task.retryCount >= MAX_RETRY_COUNT) {
|
||
throw new BadRequestException(
|
||
`已达到最大重试次数 ${MAX_RETRY_COUNT} 次,请创建新任务`
|
||
);
|
||
}
|
||
|
||
// 检查并发限制
|
||
const activeTaskCount = await this.prisma.aI3DTask.count({
|
||
where: {
|
||
userId,
|
||
status: { in: ['pending', 'processing'] },
|
||
},
|
||
});
|
||
|
||
if (activeTaskCount >= MAX_CONCURRENT_TASKS) {
|
||
throw new BadRequestException(
|
||
`您当前有 ${activeTaskCount} 个任务正在处理中,请等待完成后再重试`
|
||
);
|
||
}
|
||
|
||
// 重置任务状态
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: taskId },
|
||
data: {
|
||
status: 'pending',
|
||
errorMessage: null,
|
||
completeTime: null,
|
||
retryCount: { increment: 1 },
|
||
},
|
||
});
|
||
|
||
// 重新提交任务
|
||
const externalTaskId = await this.ai3dProvider.submitTask(
|
||
task.inputType,
|
||
task.inputContent,
|
||
);
|
||
|
||
await this.prisma.aI3DTask.update({
|
||
where: { id: taskId },
|
||
data: {
|
||
status: 'processing',
|
||
externalTaskId,
|
||
},
|
||
});
|
||
|
||
this.pollTaskStatus(taskId, externalTaskId, Date.now());
|
||
|
||
return this.getTask(userId, taskId);
|
||
}
|
||
```
|
||
|
||
#### 8.3.3 前端重试按钮
|
||
|
||
```vue
|
||
<template>
|
||
<a-card v-for="task in tasks" :key="task.id">
|
||
<template v-if="['failed', 'timeout'].includes(task.status)">
|
||
<a-button
|
||
type="link"
|
||
@click="handleRetry(task.id)"
|
||
:disabled="task.retryCount >= 3"
|
||
>
|
||
{{ task.retryCount >= 3 ? '已达重试上限' : '重试' }}
|
||
<span v-if="task.retryCount > 0">({{ task.retryCount }}/3)</span>
|
||
</a-button>
|
||
</template>
|
||
</a-card>
|
||
</template>
|
||
|
||
<script setup>
|
||
const handleRetry = async (taskId: number) => {
|
||
try {
|
||
await retryAI3DTask(taskId);
|
||
message.success('重试已提交');
|
||
fetchTasks();
|
||
} catch (error: any) {
|
||
message.error(error.response?.data?.message || '重试失败');
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
### 8.4 方案对比与推荐
|
||
|
||
| 方案 | 优点 | 缺点 | 适用场景 |
|
||
|------|------|------|----------|
|
||
| 方案A(轮询检查) | 实现简单,无需额外依赖 | 服务重启后轮询丢失 | Mock 开发、小规模部署 |
|
||
| 方案B(定时任务) | 可靠性高,服务重启不影响 | 需要引入 @nestjs/schedule | 生产环境 |
|
||
|
||
**推荐策略**:
|
||
- 开发阶段使用方案A,快速验证功能
|
||
- 生产环境使用方案B + 方案A 结合,双重保障
|
||
|
||
---
|
||
|
||
## 9. 注意事项
|
||
|
||
1. **数据归属**:任务数据通过 `userId` 关联用户,确保用户只能访问自己的数据
|
||
2. **多租户隔离**:保留 `tenantId` 字段,便于管理员按租户统计
|
||
3. **文件存储**:生成的 3D 模型文件建议存储到 OSS,已实现 OSS 集成
|
||
4. **并发控制**:每用户最多 3 个并行任务,可通过配置调整
|
||
5. **任务超时**:10 分钟未完成自动标记为超时,支持重试
|
||
6. **重试限制**:单任务最多重试 3 次,超过后需创建新任务
|
||
7. **数据清理**:30 天前的失败/超时任务自动清理
|