# 发型模板制作任务设计 ## 背景 当前项目已经具备以下基础能力: - 基于 NestJS + Prisma 的后端服务 - 基于 React + Vite 的管理端前端 - 基于 JWT 的登录态与鉴权 - 基于 GCS 的通用上传能力 - 基于 `hair_template` / `hair_template_make_task` / `system_config` 的数据库表设计 仓库中虽然已经存在模板列表页、模板详情页和模板新建页,但这些实现仍停留在旧的静态资源和占位接口阶段,主要问题包括: - 前端模板新建页仍从本地静态配置读取模特、服装、背景 - 后端 `templates` 模块仍是空骨架,无法真实创建模板或任务 - 当前新建流程提交后直接跳模板详情页,不符合“先进入任务页”的目标 - 现有模板链路没有把 `hair_template_make_task` 作为真实的异步任务来源 - step1 / step2 的 AI 制作编排尚未落地 本次新 case 的目标,是围绕 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md) 中的以下三张表,完成“发型模板制作”最小真实闭环: - `hair_template` - `hair_template_make_task` - `system_config` 用户已明确约束: - 模板新建页沿用当前前端 UI 骨架,不做大改版 - 参考发型图片上传类型固定为 `ai-template-origin-image` - AI 模特、服装、背景都从 `system_config` 动态读取 - `config_group = 'ai-model'` 提供模特 - `config_group = 'clothing'` 提供服装 - `config_group = 'background'` 提供背景 - `config_name` 作为展示名称,`config_value` 作为对应图片 URL - 新建时模特、服装、背景都采用“预设或上传二选一”,最终统一提交 URL - 提交成功后先进入新的模板制作任务页,不直接进入模板详情页 - 服务端先写入 `hair_template`,再写入 `hair_template_make_task` - step1 使用 `system_config` 中 `config_group = 'template-step1'` 的配置值作为 AI 模板 URL - step2 需要基于 `backend/src/step2/config.json` 与 `backend/src/step2/content.json`,加上运行时素材生成 zip 并上传 - 服务端和前端都采用轮询详情接口推进任务,参考现有 AI 发型模拟逻辑 ## 目标 - 实现一个真实可用的发型模板新建页。 - 支持上传参考发型图,并将图片存储为可访问 URL。 - 支持从 `system_config` 查询 AI 模特、服装、背景预设资源。 - 支持模特、服装、背景改用用户自定义上传资源。 - 提交模板后创建 1 条 `hair_template` 记录和 1 条 `hair_template_make_task` 记录。 - 后端按 `step1 -> step2` 顺序调用外部 AI 服务。 - 新增模板制作任务页,按阶段展示处理中、成功、失败三种状态。 - 服务端在任务详情查询时按节流规则刷新任务状态并推进下一阶段。 - step2 成功后回写 `hair_template.cover_url` 和 `hair_template.template_url`。 ## 非目标 - 本次不实现后台常驻 worker、消息队列或定时补偿任务。 - 本次不实现任务批量管理页。 - 本次不实现任务取消。 - 本次不实现复杂失败重试策略。 - 本次不实现模板列表页的完整筛选重构。 - 本次不改动数据库设计文档中的表结构定义。 ## 设计原则 ### 1. 数据库表结构为唯一事实来源 本次模板制作链路的字段语义完全以 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md) 为准,尤其是: - `hair_template` 表承载最终可使用的模板实体 - `hair_template_make_task` 表承载模板制作过程和阶段状态 - `system_config` 表承载模板制作所需的动态素材配置与 step1 模板配置 不新增第二套任务模型,不在前端保留旧的静态 mock 资源来源。 ### 2. 模板实体与制作任务分层 `hair_template` 与 `hair_template_make_task` 职责必须分开: - `hair_template` 存业务上可识别的模板基本信息和最终产物地址 - `hair_template_make_task` 存异步制作过程中的任务 ID、阶段资源、阶段结果和状态 这样模板列表和模板详情都可围绕模板实体展开,而任务页则围绕制作过程展开。 ### 3. 前端只轮询自家后端,不直连 AI 前端只请求本系统任务详情接口,后端统一负责: - 是否需要查询 step1 / step2 外部 AI 状态 - 何时推进下一阶段 - 何时回写数据库 - 如何把阶段状态和结果映射为前端 DTO ### 4. 在没有队列系统的前提下保持最小闭环 当前仓库没有消息队列或任务调度基础设施,因此本次采用: - 创建任务时同步发起 step1 - 任务页轮询详情接口 - 后端在详情查询时按规则刷新外部任务状态并推进 step2 这是当前复杂度下最稳妥、最可验证的闭环方案。 ### 5. 运行时文件不得污染源码目录 `backend/src/step2` 只用于存放静态模版文件: - `config.json` - `content.json` 运行期下载的 step1 图片、服装图、背景图、zip 包等中间文件必须放入系统临时目录,任务完成后尽量清理,避免把运行时产物写回源码目录。 ## 现状分析 ### 1. 后端现状 当前后端已经具备: - 鉴权体系和发型师登录态 - 通用上传能力 `POST /api/v1/storage/upload` - `simulations` 模块中的 AI 调用与“详情查询时刷新状态”的范式 - Prisma 中对 `hair_template` 与 `hair_template_make_task` 的模型映射 但当前缺失: - 真实可用的模板创建接口 - 真实可用的模板任务详情接口 - `system_config` 的 Prisma 模型和查询能力 - step1 / step2 的模板制作 AI 编排 - zip 打包与上传逻辑 ### 2. 前端现状 当前前端已经具备: - `TemplateFormPage.tsx` 的基础 UI 骨架 - 参考图上传能力 - 模板列表页与模板详情页路由 但当前缺失: - 模板新建页对动态素材接口的接入 - 模板新建页的“预设或上传二选一”交互 - 新的模板制作任务页 - 模板创建后跳转任务页的流程 - 模板列表在“制作中/失败/完成”之间的跳转分流 ## 数据模型与职责 ### 1. `hair_template` 创建模板任务时,先写入 `hair_template` 一条草稿记录,字段来源如下: - `template_name`:前端表单提交 - `status`:前端表单提交,默认 `DRAFT` - `category`:前端表单提交 - `tags`:前端表单提交,逗号拼接 - `description`:前端表单提交 - `model_url`:预设或上传后的最终 URL - `clothes_url`:预设或上传后的最终 URL - `background_url`:预设或上传后的最终 URL - `ref_hair_url`:参考发型图 URL - `hairstylist_id`:当前登录发型师 ID - `is_system_template`:固定为 `NO` 创建瞬间不要求已有: - `cover_url` - `template_url` 只有当 step2 成功后,这两个字段才会被回写完整。 ### 2. `hair_template_make_task` 任务表以“每次创建模板对应一条制作任务”为原则,主要字段职责如下: - `total_task_id`:本地生成的总任务唯一标识 - `step1_task_id`:step1 外部 AI 任务 ID - `step2_task_id`:step2 外部 AI 任务 ID - `task_status`:本地阶段状态 - `template_id`:关联模板 ID - `step1_resource`:step1 阶段资源 JSON - `step2_resource`:step2 阶段资源 JSON - `ref_hair_url` / `model_url` / `clothes_url` / `background_url`:本次任务使用的素材快照 - `step1_result_img`:step1 结果图 URL - `step2_result_img`:step2 最终封面图 URL ### 3. `system_config` 本次新增的模板素材来源与 step1 模板来源都来自 `system_config`: - `config_group = 'ai-model'`:模特资源 - `config_group = 'clothing'`:服装资源 - `config_group = 'background'`:背景资源 - `config_group = 'template-step1'`:step1 模板地址 字段映射规则: - `config_name` -> 前端展示名称 - `config_value` -> 图片 URL 或模板 URL - `id` -> 前端选项 ID ## 总体流程 ### 1. 新建模板 前端在 `TemplateFormPage` 完成以下动作: - 上传参考发型图,获取 `refHairUrl` - 读取动态素材接口,展示模特、服装、背景预设 - 允许用户为模特、服装、背景切换为自定义上传 - 整理最终生效的三个素材 URL - 调用“创建模板制作任务”接口 ### 2. 服务端创建记录并发起 step1 服务端接到创建请求后: - 校验当前发型师身份 - 校验 `template-step1` 配置存在 - 创建 `hair_template` - 创建 `hair_template_make_task` - 发起 step1 外部 AI 任务 - 保存 `step1_task_id` - 将任务状态更新为 `PROCESSING` 若 step1 无法发起,则任务直接标记为 `FAILED`。 ### 3. 跳转任务页 前端收到创建成功响应后,跳转至新的模板制作任务页,例如: - `/:locale/templates/tasks/:taskId` ### 4. 轮询任务详情并推进阶段 任务页在以下状态下持续轮询: - `WAIT` - `PROCESSING` - `STEP1_SUCCESS` 后端在详情接口中: - 查询本地任务状态 - 按节流规则判断是否需要刷新 step1 / step2 - 在 step1 成功且 step2 尚未发起时自动启动 step2 - 在 step2 成功时回写模板表 ### 5. 任务完成 当 step2 成功时: - `hair_template.cover_url = step2 最终结果图` - `hair_template.template_url = 上传后的 zip URL` - `hair_template_make_task.step2_result_img = step2 最终结果图` - `hair_template_make_task.task_status = STEP2_SUCCESS` 此前模板记录虽然存在,但不应视为可用于 AI 发型模拟的可用模板。 ## 状态流转设计 本次直接沿用 `hair_template_make_task.task_status` 的既有枚举: - `WAIT` - `PROCESSING` - `STEP1_SUCCESS` - `STEP2_SUCCESS` - `FAILED` ### 1. `WAIT` 表示本地任务已创建,但还没有成功发起 step1。 ### 2. `PROCESSING` 表示至少有一个外部阶段任务已发起,且整体制作未结束。 该状态覆盖以下两种场景: - step1 已发起,等待 step1 完成 - step2 已发起,等待 step2 完成 ### 3. `STEP1_SUCCESS` 表示 step1 已成功,且 step1 结果图已经落地并保存到任务记录。 该状态是进入 step2 之前的中间确认态。 ### 4. `STEP2_SUCCESS` 表示 step2 已成功,模板包和封面图都已生成,并且 `hair_template` 已完成最终回写。 ### 5. `FAILED` 表示任一阶段失败,或关键配置缺失、素材下载失败、zip 上传失败等不可恢复错误发生。 ## 接口设计 ### 1. 获取模板素材接口 - `GET /templates/assets` 返回结构: ```json { "models": [ { "id": "12", "name": "自然女模", "url": "https://example.com/model.jpg", "configKey": "ai-model-natural" } ], "clothes": [], "backgrounds": [] } ``` 查询规则: - `models` <- `config_group = 'ai-model'` - `clothes` <- `config_group = 'clothing'` - `backgrounds` <- `config_group = 'background'` 返回顺序遵循 `sort asc, id asc`。 ### 2. 创建模板制作任务接口 - `POST /templates/make-tasks` 请求体: ```json { "templateName": "柔和层次长发", "category": "LONG", "status": "DRAFT", "description": "适合脸周柔和修饰", "tags": ["首次建议", "层次"], "refHairUrl": "https://example.com/ref.jpg", "modelUrl": "https://example.com/model.jpg", "clothesUrl": "https://example.com/clothes.jpg", "backgroundUrl": "https://example.com/background.jpg" } ``` 响应体: ```json { "templateId": "101", "taskId": "88", "totalTaskId": "tmpl_20260628_xxx", "taskStatus": "PROCESSING" } ``` ### 3. 查询模板制作任务详情接口 - `GET /templates/make-tasks/:taskId` 响应体建议结构: ```json { "id": "88", "templateId": "101", "templateName": "柔和层次长发", "taskStatus": "PROCESSING", "errorMessage": null, "createTime": "2026-06-28T12:00:00.000Z", "refHairUrl": "https://example.com/ref.jpg", "modelUrl": "https://example.com/model.jpg", "clothesUrl": "https://example.com/clothes.jpg", "backgroundUrl": "https://example.com/background.jpg", "step1": { "taskId": "ai_step1_xxx", "status": "PROCESSING", "templateUrl": "https://example.com/step1-template", "resultImage": null }, "step2": { "taskId": "", "status": "WAIT", "bundleUrl": null, "resultImage": null }, "template": { "coverUrl": "", "templateUrl": "", "status": "DRAFT" } } ``` 接口职责: - 返回任务详情 - 在返回前推进 step1 / step2 状态 - 在成功时返回最新模板结果 - 在失败时返回可展示的错误文案 ### 4. 重试接口 本次不作为首版必做接口。 若后续补充,建议形态为: - `POST /templates/make-tasks/:taskId/retry` ## 前端页面设计 ### 1. 模板新建页 保留现有 `TemplateFormPage.tsx` 布局骨架,主要替换以下行为: - 不再读取本地静态 `hairstyleTemplateAssets` - 改为请求 `GET /templates/assets` - 模特、服装、背景都支持“预设或上传二选一” - 三项最终都统一提交 URL - 提交成功后跳任务页,不跳模板详情页 #### 模特、服装、背景交互 每一组素材提供两种来源: - 预设列表 - 自定义上传 界面上允许用户切换来源类型,当前生效来源决定最终提交 URL。 ### 2. 模板制作任务页 新增页面,例如: - `TemplateMakeTaskPage.tsx` 路由建议: - `/:locale/templates/tasks/:taskId` 页面结构: - 左侧任务概览卡 - 右侧阶段进度卡 - 下方素材确认区 #### 左侧任务概览卡 展示内容: - 模板名称 - 当前总状态 - 分类 - 标签 - 创建时间 - 失败原因 主按钮规则: - 处理中:按钮禁用,仅展示处理中提示 - 成功:展示“查看模板详情” - 失败:首版仅展示失败文案,不强制实现重试按钮 #### 右侧阶段进度卡 分两步展示: - `Step 1 发型抽取` - `Step 2 模板合成` 每一步展示: - 当前状态 - 外部任务 ID - 结果图预览 - 当前说明文案 #### 素材确认区 展示四张素材卡片: - 参考发型图 - AI 模特 - 服装 - 背景 ### 3. 模板列表与模板详情衔接 `TemplateListPage` 中的模板卡片点击逻辑建议调整为: - 若模板存在未完成或失败的制作任务,优先跳任务页 - 若模板已完成制作,跳模板详情页 `TemplateDetailPage` 仍作为制作完成后的模板详情页使用。 ## 服务端编排设计 ### 1. AI 客户端抽象 现有 `AiSimulationClient` 的 `execute(templateUrl, imageUrl)` 只适用于发型模拟的固定输入结构。 本次建议抽象为更通用的方法,例如: - `executeTemplate(template: string, inputs: Record)` 这样: - AI 发型模拟仍可使用 `inputs.images = [originImage]` - 模板制作 step1 可使用 `inputs.image = refHairUrl` - 模板制作 step2 可使用 `inputs.image = modelUrl` 结果查询方法可继续复用现有的 `getResult(taskId)` 形式。 ### 2. step1 编排 step1 的模板来源: - 从 `system_config` 读取 `config_group = 'template-step1'` - 取其中的 `config_value` 作为 AI `template` step1 请求体核心: ```json { "template": "system_config.config_value", "inputs": { "image": "refHairUrl" } } ``` step1 成功后: - 拿到 step1 结果图 URL - 下载该图片到系统临时目录 - 回写 `hair_template_make_task.step1_result_img` - 把模板 URL、结果图 URL、本地文件路径等写入 `step1_resource` - 将任务状态改为 `STEP1_SUCCESS` ### 3. step2 编排 step2 的目标是基于静态模版文件和运行时素材生成 zip 包,并将 zip 的上传 URL 作为 AI 模板地址。 运行流程: 1. 创建任务工作目录,例如 `os.tmpdir()/template-make/{totalTaskId}` 2. 复制 `backend/src/step2/config.json` 和 `backend/src/step2/content.json` 到工作目录 3. 下载并落地以下文件: - step1 结果图 -> `hairstyle.jpg` - 服装图 -> `input-outfit.jpg` - 背景图 -> `input-background.jpg` 4. 将工作目录内容打成 zip 5. 使用 `StorageService.uploadBuffer` 上传 zip 6. 得到 zip URL,作为 step2 AI 请求中的 `template` 7. 使用 `modelUrl` 作为 step2 AI 请求中的 `inputs.image` step2 请求体核心: ```json { "template": "uploaded-zip-url", "inputs": { "image": "modelUrl" } } ``` step2 成功后: - 结果图 URL 回写 `hair_template_make_task.step2_result_img` - zip URL 和结果图 URL 写入 `step2_resource` - `hair_template.cover_url = step2_result_img` - `hair_template.template_url = uploaded-zip-url` - `hair_template_make_task.task_status = STEP2_SUCCESS` ### 4. 任务推进逻辑 任务推进完全参考 AI 发型模拟当前的“查询详情时刷新状态”模式。 #### `WAIT` - 若尚未成功发起 step1,则尝试发起 step1 - 发起成功后更新为 `PROCESSING` #### `PROCESSING` - 若 `step1_task_id` 已存在且 `step1_result_img` 为空,则查询 step1 结果 - 若 step1 已成功,则下载结果图、更新 `STEP1_SUCCESS` - 若 `step2_task_id` 已存在,则查询 step2 结果 - 若 step2 已成功,则回写模板并更新 `STEP2_SUCCESS` #### `STEP1_SUCCESS` - 若 `step2_task_id` 为空,则开始组装 zip 并发起 step2 - 发起成功后将任务状态设回 `PROCESSING` #### `FAILED` - 不自动推进 - 由后续重试接口或人工处理 ## 资源与错误信息存储 本次首版尽量不改数据库表结构。 运行态附加信息统一放入: - `step1_resource` - `step2_resource` 建议 JSON 结构保持稳定,例如: ```json { "templateUrl": "https://example.com/template", "uploadedZipUrl": "https://example.com/template.zip", "localFiles": { "step1Image": "/tmp/task-1/hairstyle.jpg", "clothes": "/tmp/task-1/input-outfit.jpg", "background": "/tmp/task-1/input-background.jpg", "zip": "/tmp/task-1/template.zip" }, "resultImage": "https://example.com/result.jpg", "lastError": "step2 upload failed" } ``` 任务页展示失败文案时,优先从阶段 resource 中提取 `lastError`,若无则返回通用错误提示。 ## 文件与模块拆分建议 ### 后端 - `src/modules/templates/templates.controller.ts` - `src/modules/templates/templates.service.ts` - `src/modules/templates/templates.module.ts` - `src/modules/templates/dto/*` - `src/modules/templates/template-ai.client.ts` 或复用/扩展现有 AI client - `src/modules/templates/template-task-runner.ts` 或同类服务 ### 前端 - `src/pages/dashboard/templates/TemplateFormPage.tsx` - `src/pages/dashboard/templates/TemplateMakeTaskPage.tsx` - `src/pages/dashboard/templates/TemplateListPage.tsx` - `src/services/api/templates.ts` - `src/services/api/contracts.ts` - `src/config/routes.ts` - i18n 文案与样式文件 ## 验证与测试 ### 后端 建议补充有价值的最小测试: - `GET /templates/assets` 能按配置分组返回素材 - `POST /templates/make-tasks` 能正确创建模板与任务记录 - 任务详情查询能推进 step1 成功 - 任务详情查询能推进 step2 成功并回写模板 - 任一阶段失败时任务进入 `FAILED` ### 前端 建议补充有价值的最小测试: - 模板新建页能加载动态素材 - 模特、服装、背景切换来源后能提交最终生效 URL - 提交成功后跳转任务页 - 任务页在处理中时持续轮询,在成功或失败后停止轮询 - 任务页能正确展示步骤状态与成功 CTA ## 最小交付边界 本次首版必须完成: - 动态素材接口 - 模板新建真实提交 - `hair_template` + `hair_template_make_task` 双写入 - step1 / step2 真实跑通 - 模板制作任务页 - 任务状态推进 - 成功后回写 `hair_template.cover_url` 与 `hair_template.template_url` 本次首版可以不做: - 任务重试接口 - 后台扫表补偿 - 任务取消 - 模板列表复杂筛选联动 - 模板详情页的深度中间态优化