2026-06-28-ai-hair-simulation-design.md 21 KB

AI 发型模拟设计

背景

当前项目已经具备以下基础能力:

  • 基于 NestJS + Prisma 的后端服务
  • 基于 React + Vite 的管理端前端
  • 基于 JWT 的登录态与鉴权
  • 基于 GCS 的通用上传能力
  • 顾客详情页与顾客列表页

仓库中虽然已经存在 simulations、templates 以及若干模拟相关前端页面,但这些实现仍停留在旧的业务假设中,主要问题包括:

  • 前端把模拟流程拆成了 hairstyle / aftercare 两条旧链路
  • 结果页、微调页、剪后预测页依赖旧的 job/results 语义
  • 后端 simulations、templates 模块仍是未完成骨架
  • 现有前端模拟逻辑与最新数据库设计不一致

本次新 case 的目标,是围绕 数据库设计.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 为准,尤其是:

  • 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 中“开始模拟”按钮仍为禁用态。

本次调整为:

  • 点击后跳转 /:locale/simulation?customerId={customer.id}
  • 进入创建页后默认选中该顾客
  • 用户仍可以切换其他顾客,或改成不绑定顾客

顾客详情页中的其他未开发模块保持原样,不在本次扩展。

未开发页面策略

1. 微调页

SimulationEditPage 保留页面和路由,但统一改为未开发态:

  • 标题保留为“微调”
  • 展示“当前版本暂未开发”
  • 提供返回任务详情页或返回创建页的入口
  • 删除所有旧的结果 ID、调整参数和微调提交逻辑

2. 剪后预测页

AftercareResultPage 保留页面和路由,但统一改为未开发态:

  • 标题保留
  • 展示“当前版本暂未开发”
  • 删除旧的 aftercare 数据请求与交互逻辑

这满足用户提出的:

  • aftercare 不做
  • 微调 页面不要删除,但明确标记未开发

后端接口设计

1. 模板列表接口

建议新增:

  • GET /api/v1/simulations/template-options

请求参数建议:

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'

返回字段建议:

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

请求体:

type CreateAiHairSimulationRequest = {
  customerId?: string
  originImage: string
  templateIds: string[]
  simulateRemark?: string
}

规则:

  • originImage 必填
  • templateIds 至少 1 个
  • 如传 customerId,必须属于当前登录发型师
  • 所有模板必须存在、未删除、处于 PUBLIC,且属于:
    • 当前发型师自己的模板,或
    • 系统模板

服务端处理顺序:

  1. 校验顾客与模板合法性
  2. 基于每个模板调用 AI POST /execute
  3. 如果全部 AI 创建成功,统一事务写入主表和子表
  4. 返回创建结果和 simulationId

推荐响应结构:

type CreateAiHairSimulationResponse = {
  simulationId: string
}

3. 详情接口

接口:

  • GET /api/v1/simulations/:simulationId

职责:

  • 读取模拟单主记录
  • 读取所有子任务
  • 对需要刷新的 PROCESSING 子任务查询 AI 结果
  • 回写数据库
  • 返回聚合后的完整详情

返回结构建议:

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 请求体映射规则:

{
  "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. 保留微调页和剪后预测页的页面壳,但明确标记未开发。