# AI 发型模拟设计 ## 背景 当前项目已经具备以下基础能力: - 基于 NestJS + Prisma 的后端服务 - 基于 React + Vite 的管理端前端 - 基于 JWT 的登录态与鉴权 - 基于 GCS 的通用上传能力 - 顾客详情页与顾客列表页 仓库中虽然已经存在 `simulations`、`templates` 以及若干模拟相关前端页面,但这些实现仍停留在旧的业务假设中,主要问题包括: - 前端把模拟流程拆成了 `hairstyle / aftercare` 两条旧链路 - 结果页、微调页、剪后预测页依赖旧的 `job/results` 语义 - 后端 `simulations`、`templates` 模块仍是未完成骨架 - 现有前端模拟逻辑与最新数据库设计不一致 本次新 case 的目标,是围绕 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md) 中的以下三张表,完成“AI 发型模拟”最小真实闭环: - `ai_hair_simulation` - `ai_hair_simulation_task` - `hair_template` 用户已明确约束: - 本次只实现“AI 发型模拟”主流程,不实现剪后预测 - `微调` 页面不要删除,但只能保留为未开发状态 - 从顾客详情页进入时,默认带入当前顾客,但允许切换顾客或改成不绑定顾客 - 从控制台进入时,需要允许选择顾客,也允许不选择顾客 - 顾客原图上传类型固定为 `ai-user-origin-image` - 系统模板后续会补充到数据库中,本次只需按真实查询能力接入,不需要硬编码模板数据 - 提交成功后进入任务详情页,任务详情页按子任务逐项展示状态 - 状态刷新采用“前端轮询后端详情接口,后端按规则刷新 AI 结果并回写数据库”的方案 ## 目标 - 实现一个真实可用的 AI 发型模拟创建页。 - 支持从控制台入口和顾客详情入口进入同一页面。 - 支持绑定顾客或匿名模拟两种模式。 - 支持上传顾客原图并将图片存储为可访问 URL。 - 支持从 `hair_template` 中查询“发型师模板”和“系统模板”。 - 提交模拟时创建 1 条主记录和多条子任务记录。 - 后端根据模板 URL 调用外部 AI 服务。 - 任务详情页按模板逐项展示处理中、成功、失败三种状态。 - 服务端在详情查询时按节流规则刷新未完成子任务状态并回写数据库。 - 清理或替换旧模拟链路中与本次 case 不一致的逻辑。 ## 非目标 - 本次不实现 `aftercare` / 剪后预测真实能力。 - 本次不实现“微调”真实能力。 - 本次不实现模拟结果保存回顾客档案。 - 本次不实现模拟历史列表页。 - 本次不实现失败任务重试。 - 本次不实现消息队列、定时任务调度或后台常驻轮询。 - 本次不负责补齐系统模板种子数据。 ## 设计原则 ### 1. 数据库表结构为唯一事实来源 本次模拟链路的字段语义完全以 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md) 为准,尤其是: - `ai_hair_simulation.origin_image` 存原图 URL - `ai_hair_simulation_task.task_status` 存子任务状态 - `hair_template.template_url` 作为 AI 调用时的模板地址 - `hair_template.is_system_template` 作为系统模板与发型师模板的来源区分字段 不再沿用旧前端页面中的: - `simulationType = hairstyle | aftercare` - 旧 `job/results` 数据模型 - 旧 aftercare 选项 - 旧微调结果 ID 依赖 ### 2. 页面可以保留,旧业务逻辑必须清理 用户明确要求“微调页面不要删除”,因此页面壳和路由可以保留;但旧业务逻辑、旧 API 契约和旧状态模型需要删除或改写,避免两套模拟模型并存。 ### 3. 前端只轮询自家后端,不直连 AI 前端只请求本系统详情接口,后端统一负责: - 是否需要查询 AI 结果 - 何时刷新子任务状态 - 如何把 AI 返回映射回数据库和前端 DTO 这样数据库状态始终是唯一事实源,前端不会出现“页面看到的状态”和数据库状态不一致的问题。 ### 4. 在没有队列系统的前提下保持最小闭环 当前仓库没有消息队列或定时任务基础设施,因此本次采用: - 创建时同步发起 AI 任务 - 详情页打开后由后端按规则刷新未完成任务 这是当前复杂度下最稳妥、最可验证的闭环方案。 ## 现状分析 ### 1. 后端现状 当前后端已经具备: - 认证体系和发型师登录态 - 通用上传能力 `POST /api/v1/storage/upload` - `simulations` 模块空骨架 - `templates` 模块空骨架 但当前缺失: - 可用的模拟创建接口 - 可用的模拟详情接口 - AI 服务 HTTP 客户端 - 基于 `hair_template` 的模板列表接口 - `ai_hair_simulation` 与 `ai_hair_simulation_task` 的真实读写 ### 2. 前端现状 当前前端已经具备: - 顾客详情页与顾客列表页 - 旧版 `StylistSimulationPage` - 旧版 `SimulationResultPage` - 旧版 `SimulationEditPage` - 旧版 `AftercareResultPage` - 通用文件上传 API 封装 但当前存在明显不一致: - 创建页围绕 `selected/guest + hairstyle/aftercare` 建模 - 结果页依赖旧 `jobId` 和旧 `results` 接口 - 微调页和剪后预测页依赖旧链路 - `simulationSession` 状态模型不适合真实任务详情页 ### 3. 数据表现状 根据最新表结构,`hair_template` 现已具备本次查询所需关键字段: - `template_name` - `cover_url` - `status` - `category` - `tags` - `description` - `template_url` - `hairstylist_id` - `is_system_template` 这意味着本次无需额外拼接模板来源,也无需依赖前端静态模板配置。 ## 方案选择 本次采用“后端主导任务编排 + 详情页触发按规则刷新”的方案。 ### 采用方案 - 创建模拟时,后端同步调用 AI `POST /execute` - 成功后统一事务写入: - `ai_hair_simulation` - 多条 `ai_hair_simulation_task` - 任务详情查询时: - 先读取数据库 - 再按刷新规则查询仍处于 `PROCESSING` 的子任务 - 将 AI 返回结果回写数据库 - 最后返回聚合详情给前端 ### 不采用方案 - 仅创建任务,不在详情接口里做刷新: - 当前没有后台调度基础设施,任务状态无法自然推进 - 前端直接调用 AI 查询结果: - 会破坏数据库作为唯一事实源的设计 - 先做消息队列或定时任务系统: - 复杂度过高,超出本次最小闭环范围 ## 路由与页面设计 ### 1. 路由设计 建议前端相关路由收口为: - `/:locale/simulation` - AI 发型模拟创建页 - `/:locale/simulation/result/:simulationId` - AI 模拟任务详情页 - `/:locale/simulation/edit` - 微调页,保留但未开发 - `/:locale/simulation/aftercare` - 剪后预测页,保留但未开发 其中: - 顾客详情页通过 `/:locale/simulation?customerId={id}` 进入创建页 - 控制台直接通过 `/:locale/simulation` 进入创建页 ### 2. 创建页 创建页继续使用 `StylistSimulationPage`,但需整体重写为本次业务语义。 #### 2.1 Step 1 顾客选择 交互规则: - 从控制台进入: - 默认“不绑定顾客” - 从顾客详情进入: - 读取 URL 上的 `customerId` - 若顾客存在且属于当前发型师,则默认选中该顾客 页面需支持两种模式: - `绑定顾客` - `不绑定顾客` 当选择 `绑定顾客` 时: - 展示顾客选择器 - 允许切换成其他顾客 - 允许用户改回 `不绑定顾客` 不再使用旧的: - `selected` - `guest` 而直接使用更贴近业务语义的文案。 #### 2.2 Step 2 上传原图 图片上传沿用通用上传能力,规则固定为: - 调用 `uploadMedia(file, 'ai-user-origin-image')` 上传成功后前端保留: - 本地预览 - 服务端返回的原图 URL 最终创建模拟时传原图 URL,而不是直接传 `File`。 #### 2.3 Step 3 选择模板 模板选择区固定拆分为两个来源 Tab: - `我的模板` - `系统模板` 底层数据都来自 `hair_template`,区别只在筛选条件。 可选筛选项: - `category` - `q` - `page` - `pageSize` 模板卡片展示: - 模板封面 `cover_url` - 模板名称 `template_name` - 分类 `category` - 标签 `tags` - 来源标识 本次允许多选模板,且至少选择 1 个。 ### 3. 任务详情页 `SimulationResultPage` 重写为真正的任务详情页,不再沿用旧的 `job + results` 模型。 #### 3.1 顶部操作栏 - 返回按钮: - 绑定顾客时返回对应顾客详情页 - 匿名模拟时返回创建页或控制台 - 重新发起模拟按钮: - 返回创建页 - 如有顾客则自动带上 `customerId` #### 3.2 Hero 区 绑定顾客时展示: - 顾客头像 - 顾客姓名 - 顾客手机号 - 期望联系渠道 匿名模拟时展示: - “匿名模拟”标题 - 未关联顾客档案的提示文案 右侧展示模拟单汇总信息: - 总模板数 - 成功数 - 失败数 - 处理中数 - 聚合状态 #### 3.3 详情主体 左侧信息区: - 原图预览 - 模拟备注 右侧子任务区: - 按模板逐项展示子任务卡片 - 每张卡片展示: - 模板封面 - 模板名称 - 模板来源 - 状态标签 - 失败原因 - 结果图列表 ## 顾客详情页入口调整 当前 [CustomerDetailPage](file:///Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx) 中“开始模拟”按钮仍为禁用态。 本次调整为: - 点击后跳转 `/:locale/simulation?customerId={customer.id}` - 进入创建页后默认选中该顾客 - 用户仍可以切换其他顾客,或改成不绑定顾客 顾客详情页中的其他未开发模块保持原样,不在本次扩展。 ## 未开发页面策略 ### 1. 微调页 `SimulationEditPage` 保留页面和路由,但统一改为未开发态: - 标题保留为“微调” - 展示“当前版本暂未开发” - 提供返回任务详情页或返回创建页的入口 - 删除所有旧的结果 ID、调整参数和微调提交逻辑 ### 2. 剪后预测页 `AftercareResultPage` 保留页面和路由,但统一改为未开发态: - 标题保留 - 展示“当前版本暂未开发” - 删除旧的 aftercare 数据请求与交互逻辑 这满足用户提出的: - `aftercare` 不做 - `微调` 页面不要删除,但明确标记未开发 ## 后端接口设计 ### 1. 模板列表接口 建议新增: - `GET /api/v1/simulations/template-options` 请求参数建议: ```ts type ListSimulationTemplateOptionsQuery = { source: 'STYLIST' | 'SYSTEM' category?: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE' q?: string page?: number pageSize?: number } ``` 查询规则: 公共条件: - `is_deleted = 0` - `status = 'PUBLIC'` - `template_url` 非空 发型师模板: - `is_system_template = 'NO'` - `hairstylist_id = 当前登录发型师` 系统模板: - `is_system_template = 'YES'` 返回字段建议: ```ts type SimulationTemplateOption = { id: string templateName: string coverUrl: string category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE' tags: string[] description: string | null templateUrl: string isSystemTemplate: boolean } ``` ### 2. 创建模拟接口 接口: - `POST /api/v1/simulations` 请求体: ```ts type CreateAiHairSimulationRequest = { customerId?: string originImage: string templateIds: string[] simulateRemark?: string } ``` 规则: - `originImage` 必填 - `templateIds` 至少 1 个 - 如传 `customerId`,必须属于当前登录发型师 - 所有模板必须存在、未删除、处于 `PUBLIC`,且属于: - 当前发型师自己的模板,或 - 系统模板 服务端处理顺序: 1. 校验顾客与模板合法性 2. 基于每个模板调用 AI `POST /execute` 3. 如果全部 AI 创建成功,统一事务写入主表和子表 4. 返回创建结果和 `simulationId` 推荐响应结构: ```ts type CreateAiHairSimulationResponse = { simulationId: string } ``` ### 3. 详情接口 接口: - `GET /api/v1/simulations/:simulationId` 职责: - 读取模拟单主记录 - 读取所有子任务 - 对需要刷新的 `PROCESSING` 子任务查询 AI 结果 - 回写数据库 - 返回聚合后的完整详情 返回结构建议: ```ts type AiHairSimulationDetail = { id: string customer: { id: string name: string avatar: string phone: string expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE' } | null originImage: string simulateRemark: string | null totalTaskCount: number processingTaskCount: number successTaskCount: number failedTaskCount: number overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED' tasks: Array<{ id: string templateId: string templateName: string templateCoverUrl: string templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE' templateSource: 'STYLIST' | 'SYSTEM' taskId: string taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED' resultImages: string[] errorMsg: string | null taskStartTime: string taskFinishTime: string | null }> } ``` 聚合状态规则: - 只要还有任一 `PROCESSING` -> `PROCESSING` - 全部成功 -> `SUCCESSES` - 全部失败 -> `FAILED` - 同时存在成功与失败 -> `PARTIAL_SUCCESS` 这个聚合状态不落库,只在详情查询时动态计算。 ## 数据库读写设计 ### 1. 主表 `ai_hair_simulation` 每次创建模拟只生成 1 条主记录: - `customer_id` - `hairstylist_id` - `origin_image` - `simulate_remark` ### 2. 子表 `ai_hair_simulation_task` 每个模板生成 1 条子任务记录: - `simulation_id` - `template_id` - `customer_id` - `task_id` - `task_status` - `result_images` - `task_start_time` - `task_finish_time` - `error_msg` 初始值: - `task_status = PROCESSING` - `result_images = NULL` - `task_finish_time = NULL` - `error_msg = NULL` ### 3. AI 调用字段映射 AI `POST /execute` 请求体映射规则: ```json { "template": "hair_template.template_url", "inputs": { "images": ["ai_hair_simulation.origin_image"] } } ``` 也就是说: - `cover_url` 仅用于前端展示 - `template_url` 才是真正发给 AI 服务的模板地址 ## AI 服务接入设计 ### 1. 环境变量 新增配置: - `AI_SIMULATION_BASE_URL` - `AI_SIMULATION_REFRESH_INTERVAL_MS` - `AI_SIMULATION_HTTP_TIMEOUT_MS` 建议默认值: - `AI_SIMULATION_BASE_URL=http://localhost:8084` - `AI_SIMULATION_REFRESH_INTERVAL_MS=10000` - `AI_SIMULATION_HTTP_TIMEOUT_MS=15000` ### 2. AI 客户端服务 建议新增独立客户端,例如: - `AiSimulationClient.execute(templateUrl, imageUrl)` - `AiSimulationClient.getResult(taskId)` 职责: - 隔离外部 HTTP 协议细节 - 将 AI 返回映射成内部统一结构 - 供 `simulations` 模块复用 不负责: - 业务权限判断 - 数据库存储 - DTO 聚合 ## 详情页触发刷新规则 这是本次方案的核心。 当 `GET /simulations/:simulationId` 被调用时: 1. 读取主记录与所有子任务 2. 找出所有 `task_status = PROCESSING` 的子任务 3. 对每个子任务判断是否满足刷新条件 4. 满足条件的才调用 AI `GET /result` 5. 按 AI 返回结果回写数据库 6. 用最新数据重新组装详情 DTO 返回前端 ### 刷新节流规则 建议规则: - 仅当子任务距离上次更新时间超过 `AI_SIMULATION_REFRESH_INTERVAL_MS` 时,才查询 AI - 单次详情请求中,每个子任务最多查询 1 次 - 前端连续轮询详情时,后端不会每次都触发 AI 请求 当前阶段可直接利用 `update_time` 作为“最近刷新时间”的判断基准,不额外新增字段。 ### 状态映射 AI `GET /result` 返回 `status` 与数据库状态映射如下: - `processing` -> `PROCESSING` - `successes` -> `SUCCESSES` - `failed` -> `FAILED` 成功时回写: - `task_status = SUCCESSES` - `result_images` - `task_finish_time` 失败时回写: - `task_status = FAILED` - `error_msg` - `task_finish_time` 处理中时: - 保持 `PROCESSING` - 不写 `task_finish_time` ## 创建时的事务策略 本次不采用“先写数据库,再慢慢补 taskId”的方案。 推荐顺序: 1. 先校验顾客与模板 2. 逐个调用 AI `POST /execute` 获取所有 `taskId` 3. 若全部成功,再事务写入: - `ai_hair_simulation` - 所有 `ai_hair_simulation_task` 优点: - 不会产生主单已创建但子任务缺失的残缺数据 - 失败时直接整单失败,便于保持当前阶段的数据整洁 当前阶段不做: - 部分成功写库 - 创建后补偿 - 自动重试 ## 前端 API 层设计 建议将 `frontend/src/services/api/simulations.ts` 收口为只保留本次 case 所需能力: - `listSimulationTemplates(params)` - `createSimulation(input)` - `getSimulationById(simulationId)` 建议新增或更新的数据类型: - `SimulationTemplateOption` - `AiHairSimulationDetail` - `AiHairSimulationTaskDetail` 建议删除旧接口: - `getSimulationJob` - `getSimulationResults` - `waitForSimulationJob` - `saveSimulationResult` - `adjustSimulationResult` - `saveAftercareFollow` 旧的 `simulationSession` 若仍只服务旧链路,应一并清理或重构为仅保留必要导航上下文。 ## 前端轮询设计 前端详情页只轮询: - `GET /api/v1/simulations/:simulationId` 建议策略: - 首次进入页面立即请求 1 次 - 若 `overallStatus = PROCESSING` - 每 5 秒轮询 1 次 - 若状态已终态 - 停止轮询 这样前端与后端配合节奏为: - 前端 5 秒轮询一次详情 - 后端对单个子任务 10 秒内最多查询 AI 一次 ## 错误处理语义 ### 1. 创建阶段 以下情况返回错误: - 原图缺失 - 模板为空 - 模板不存在或不可用 - 顾客不存在或不属于当前发型师 - AI `execute` 调用失败 当前策略: - 任一模板执行创建失败,则整单失败 - 不创建任何数据库记录 ### 2. 详情阶段 若模拟单不存在或不属于当前发型师: - 返回 `404` 若 AI 查询结果失败: - 当前次刷新失败不影响已存在数据库记录 - 子任务维持原状态 - 详细错误仅记录服务端日志 这可以避免因为一次瞬时网络抖动就把原本仍在运行的任务错误标成失败。 ## 测试策略 ### 1. 后端单元测试 至少覆盖: 1. 模板列表按 `source` 正确筛选系统模板与发型师模板 2. 创建模拟时仅允许访问当前发型师自己的模板和系统模板 3. 创建模拟时若顾客不属于当前发型师则失败 4. 创建成功时正确写入 1 条主记录与 N 条子任务记录 5. 详情接口能正确聚合 `overallStatus` 6. 详情接口只刷新满足节流规则的 `PROCESSING` 子任务 7. AI 返回 `processing / successes / failed` 时正确映射数据库状态 ### 2. 后端 e2e 测试 至少覆盖: 1. 未登录访问创建接口返回 `401` 2. 创建模拟成功 3. 匿名模拟创建成功 4. 详情接口返回带子任务的聚合详情 5. 非所属发型师访问任务详情返回 `404` ### 3. 前端测试 至少覆盖: 1. 从控制台进入创建页时默认不绑定顾客 2. 从顾客详情进入时默认选中当前顾客 3. 创建页允许切换顾客,也允许改成不绑定顾客 4. 上传原图时调用 `ai-user-origin-image` 5. 模板来源 Tab 切换后能请求不同来源模板 6. 创建成功后跳到 `/:locale/simulation/result/:simulationId` 7. 详情页在处理中时轮询后端详情接口 8. 微调页与剪后预测页显示未开发状态 ## 风险与约束 ### 1. 当前 `ai_hair_simulation_task.template_id` 为字符串 而 `hair_template.id` 是 `BIGINT`。本次实现需统一转换策略,建议前后端都以字符串承接,后端查询时再转成 `BigInt`。 ### 2. `template_url` 是 AI 调用强依赖字段 即使系统模板数据后续补充,本次也必须对 `template_url` 非空做校验,否则会造成 AI 创建失败。 ### 3. 详情接口兼具读取与刷新职责 这是一种为当前阶段做的折中。随着后续任务系统变复杂,未来可能需要拆为: - 纯详情读取 - 后台轮询刷新 但当前阶段不必提前引入更重的基础设施。 ### 4. 旧模拟链路残留代码较多 实施时要警惕只“叠加新逻辑”而不删除旧逻辑,避免: - 路由能进但数据模型冲突 - API 文件中同时存在新旧契约 - 页面仍偷偷依赖旧 session 状态 ## 验收标准 - 控制台进入 AI 模拟页时默认不绑定顾客。 - 顾客详情页进入 AI 模拟页时默认选中当前顾客。 - 用户可以切换顾客,也可以改成不绑定顾客。 - 原图上传类型固定为 `ai-user-origin-image`。 - 模板列表可以区分“我的模板”和“系统模板”。 - 模板数据来自 `hair_template`,AI 调用使用 `template_url`。 - 提交模拟后创建 1 条 `ai_hair_simulation` 和多条 `ai_hair_simulation_task`。 - 提交成功后进入任务详情页 `/:locale/simulation/result/:simulationId`。 - 任务详情页按子任务逐项展示处理中、成功、失败状态。 - 后端在详情查询时按节流规则刷新未完成子任务并回写数据库。 - 微调页与剪后预测页保留页面,但明确标记为未开发。 - 旧 `aftercare` 和旧模拟结果链路不再作为真实业务实现存在。 ## 最终结论 本次 case 的正确实现方式是: 1. 用新的创建页和任务详情页替换旧模拟主流程。 2. 用 `hair_template` 的真实字段直接支撑模板查询与 AI 调用。 3. 用 `ai_hair_simulation` + `ai_hair_simulation_task` 作为任务事实源。 4. 让后端在详情查询时按规则刷新 AI 子任务状态,而不是让前端直连 AI。 5. 保留微调页和剪后预测页的页面壳,但明确标记未开发。