# 顾客模块重做设计(以数据库设计为准) ## 1. 背景 当前顾客模块已经做过一轮实现,但起点选错了: - 后端 Prisma / migration 使用了另一套顾客字段模型 - 前端表单和列表也围绕这套错误字段模型做了适配 - 这套模型与用户明确指定的 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md#L16-L54) 不一致 用户已经明确要求: - 顾客模块必须以 `backend/docs/数据库设计.md` 中 `customer` 表定义为唯一事实来源 - 当前偏离这份设计的实现,需要整体回到这份 SQL 上重做 - 开发完成后,旧文档内容也要被新内容替换,避免继续误导后续开发 本次设计用于重做顾客模块最小闭环: - 新建顾客 - 顾客分页列表与筛选 - 顾客统计接口 顾客详情页仍然是下一个 case,本次不实现。 ## 2. 范围 ### 本次实现 1. 按 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md#L16-L54) 重建 `customer` 表对应的 Prisma 模型与 migration 2. 重做 `POST /api/v1/customers` 3. 重做 `GET /api/v1/customers` 4. 重做 `GET /api/v1/customers/summary` 5. 前端新建页、列表页、统计卡片按真实字段重新适配 6. 保留头像上传能力,上传 `type` 固定为 `avatar` 7. 开发完成后,用新实现对应的内容替换旧的顾客文档描述 ### 本次不实现 1. 顾客详情页 2. 编辑顾客 3. 顾客历史记录接口 4. 提醒发送接口 5. 施术记录聚合页 6. AI 模拟详情聚合页 ## 3. 设计原则 ### 3.1 唯一事实来源 本次顾客模块的结构定义,唯一以 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md#L16-L54) 为准。 这意味着: - Prisma model 要贴近这份表结构 - migration 要贴近这份表结构 - DTO 和 service 以这份字段语义为中心 - 前端做适配,不反向驱动数据库列设计 ### 3.2 旧错误模型不继续兼容 当前仓库里已经存在一套错误起点的字段模型,例如: - `kana` - `avatar_url` - `contact_type` - `line_status` - `last_visit_date` - `next_visit_date` - `visit_cycle` - `hair_type` - `hair_condition` - `concern` - `desired_image` - `formula_note` - `private_note` - `follow_status` - `follow_timing` - `follow_message` 这些字段不再作为顾客主模型继续保留。 处理原则: - 如果能映射到新表语义,就在接口层做一次性迁移映射 - 如果无法映射,则直接删除旧设计残留,不做双模型并存 ### 3.3 头像策略 - 顾客头像不是必填 - 前端上传头像时,继续复用已完成的存储能力 - 上传 `type` 固定为 `avatar` - 若前端未上传头像,后端自动写入默认头像 URL 默认头像 URL: `https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png` ### 3.4 分页策略 - 顾客列表采用 `page + pageSize` - 即使只有 1 页,前端也保留分页控件显示 - `total` 表示当前筛选条件下的真实总数 ## 4. 数据模型 ### 4.1 `customer` 表字段 本次实现必须与以下字段语义对齐: - `id` - `name` - `name_pronunciation` - `phone` - `email` - `birthday` - `avatar` - `expect_contact` - `hair_length` - `hair_texture` - `notice` - `allergy` - `inner_remark` - `next_follow_date` - `recommend_visit_date` - `first_suggest_hair` - `plan_date` - `is_first_register` - `visit_purpose` - `trouble` - `consult_content` - `expect_look` - `create_time` - `update_time` - `is_deleted` ### 4.2 与关联表的关系 当前数据库设计文档中的 `customer` 表没有 `hairstylist_id` 字段,但业务约束要求所有顾客数据按 JWT 中的发型师隔离。 因此本次需要做一个明确落地: - **在实现层补充 `hairstylist_id` 字段到真实表结构** 原因: - 没有 `hairstylist_id`,无法满足“所有顾客数据必须按当前发型师隔离”的硬约束 - 仅靠手机号或外部表无法可靠完成创建、列表和统计的隔离 - 当前项目的鉴权与其他业务表都已经围绕 `hairstylist_id` 做隔离 因此最终真实落地的 `customer` 表应视为: - 以 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md#L16-L54) 为主体 - 补入 `hairstylist_id` 这一业务必需字段 这是本次唯一允许的结构性补充,其余字段语义不另起新模型。 ### 4.3 枚举建议 建议 Prisma 侧按以下枚举建模: - `expect_contact`: `LINE | PHONE | EMAIL | NONE` - `hair_length`: `SHORT | MEDIUM | LONG | UNCONFIRM` - `hair_texture`: `UNCONFIRM | THIN | NORMAL | HARD_CRUDE | CURLY` - `first_suggest_hair`: `AUTO | LONG | MEDIUM | SHORT` - `visit_purpose`: `CUT_DYE | CUT | DYE | CONSULT_SIM` ## 5. 前后端字段映射 ### 5.1 新建页表单到数据库字段 前端字段与数据库字段重新对齐如下: - `name` -> `name` - `kana` -> `name_pronunciation` - `phone` -> `phone` - `email` -> `email` - `birthday` -> `birthday` - `image` -> `avatar` - `contact` -> `expect_contact` - `hairLength` -> `hair_length` - `hairType` -> `hair_texture` - `formulaNote` / `hairCondition` -> 不再保留旧字段语义 - `allergyNote` -> `allergy` - `privateNote` -> `inner_remark` - `followTiming` -> `next_follow_date` - `nextVisit` -> `recommend_visit_date` - `followStatus` -> 不再直存数据库枚举,改由日期业务推导 - `visitCycle` -> 不再作为独立列,按需要映射到 `visit_purpose` - `lastVisit` -> 本次不作为主字段落库 - `concern` -> `trouble` - `desiredImage` -> `expect_look` - `followMessage` -> `consult_content` 或 `notice` 不合适,故本次应从前端表单中移除旧语义字段 - `lineStatus` -> 本次没有对应列,应从当前顾客主表单移除 - `notice` 新增独立承接“发型护理注意事项” - `planDate` 对应 `plan_date` - `isFirstRegister` 对应 `is_first_register` - `visitPurpose` 对应 `visit_purpose` ### 5.2 关键调整 本次不是简单改字段名,而是把前端表单也重新收口到真实业务语义: - 删掉没有数据库落点的旧字段 - 补上数据库真实字段但前端当前未明确承接的字段 - 避免继续出现“前端名字看似能填,数据库其实存的是另一回事” ## 6. API 设计 ### 6.1 新建顾客 `POST /api/v1/customers` 请求体以真实业务字段为中心,建议形态: ```ts type CreateCustomerRequest = { name: string namePronunciation?: string phone: string email?: string birthday?: string avatar?: string expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE' hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM' hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY' notice?: string allergy?: string innerRemark?: string nextFollowDate?: string recommendVisitDate?: string firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT' planDate?: string isFirstRegister?: boolean visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' trouble?: string consultContent?: string expectLook?: string } ``` 规则: - `name` 必填 - `phone` 必填 - `avatar` 非必填 - 若 `avatar` 未传,则后端自动写默认头像 URL - `hairstylist_id` 不从前端传,后端从 JWT 中写入 ### 6.2 顾客分页列表 `GET /api/v1/customers` 查询参数: ```ts type ListCustomersQuery = { page?: number pageSize?: number q?: string visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK' followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP' hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM' } ``` 返回: ```ts type ListCustomersResponse = { items: CustomerListItem[] total: number page: number pageSize: number } ``` ### 6.3 顾客统计 `GET /api/v1/customers/summary` 返回: ```ts type CustomerSummaryResponse = { total: number today: number followUp: number simulated: number } ``` ## 7. 查询规则 ### 7.1 搜索词 `q` 模糊查询以下字段: - `name` - `phone` - `inner_remark` - `trouble` - `consult_content` ### 7.2 到店状态 `visitStatus` 按 `plan_date` 判断: - `TODAY`: `plan_date = 今天` - `THIS_WEEK`: `plan_date` 落在本周 这里明确结论: - 当前 SQL 设计不支持“30日以上未到店”的精确含义 - 本次必须去掉旧模型里依赖 `last_visit_date` 的实现 - 前端筛选项需要同步收口,不能保留一个数据库无法正确支持的条件 ### 7.3 跟进状态 `followStatus` 当前 SQL 设计没有 `follow_status` 枚举列,因此本次改为业务推导: - `NEED_FOLLOW_UP`: `next_follow_date` 非空,且日期小于等于今天 - `NO_NEED_FOLLOW_UP`: 其余情况 这也意味着: - 列表中的“状态”列不再读数据库枚举 - 前端展示文案由后端或前端映射层根据日期推导结果展示 ### 7.4 发长 `hairLength` 精确匹配 `hair_length` ## 8. 统计规则 ### 8.1 顾客总数 - 当前登录发型师名下 - `is_deleted = 0` ### 8.2 今天到店 - `plan_date = 今天` ### 8.3 需跟进 - `next_follow_date` 非空 - `next_follow_date <= 今天` ### 8.4 已模拟 - `ai_hair_simulation.customer_id` 非空 - 按 `customer_id` 去重 - 仅统计当前发型师名下顾客 ## 9. 列表项返回字段 列表页只返回当前页面真正需要的字段: - `id` - `name` - `phone` - `avatar` - `planDate` - `trouble` - `hairLength` - `followState` 其中: - `followState` 是接口返回的展示语义字段,不要求数据库存在同名列 ## 10. 鉴权与数据隔离 所有顾客接口必须按 JWT 中的 `hairstylist_id` 隔离。 规则: - 创建时写入 `hairstylist_id` - 列表时按 `hairstylist_id` 过滤 - 统计时按 `hairstylist_id` 过滤 ## 11. 实施边界 ### 11.1 后端 本次重做: - Prisma schema 中 `Customer` model - 对应 migration - DTO - mapper - `CustomersService.create()` - `CustomersService.list()` - `CustomersService.getSummary()` 本次不做: - `getById()` - `update()` - 历史记录 / 提醒 / 详情聚合 ### 11.2 前端 本次重做: - 顾客新建页字段承接 - 顾客表单到 API payload 的映射 - 顾客列表展示字段 - 顾客列表筛选项语义 - 顾客状态列显示 - 顾客分页展示 本次不做: - 顾客详情页 - 顾客编辑页 ## 12. 文档策略 开发完成后,旧文档必须同步替换,避免误导: - `backend/docs/数据库设计.md` 中与顾客模块相关的内容,要更新为最终真实落地版本 - 不保留旧错误模型的描述 - 不保留“曾经试过的另一套字段名”作为参考内容 文档目标是: - 新人只看文档就能理解真实实现 - 文档与 Prisma / migration / API / 前端字段语义保持一致 ## 13. 测试设计 ### 13.1 后端单测 至少覆盖: 1. 未传头像时自动写默认头像 2. 创建时写入当前 `hairstylist_id` 3. 查询条件按 `plan_date / next_follow_date / hair_length` 正确构造 4. 统计逻辑按新口径成立 ### 13.2 后端 e2e 至少覆盖: 1. 未登录访问返回 `401` 2. 创建顾客成功 3. 分页列表成功 4. 列表筛选成功 5. summary 成功 ### 13.3 前端测试 至少覆盖: 1. 新建页 payload 字段映射符合新模型 2. 列表状态列显示业务推导结果 3. 分页控件始终显示 4. 已移除不再受支持的旧筛选 / 旧字段依赖 ## 14. 风险与约束 ### 14.1 当前前端表单与 SQL 不完全同构 需要明确删减旧字段,并补上新字段,不做“看起来能填,实际硬映射”的假适配。 ### 14.2 当前筛选项存在旧模型残留 `30日以上未到店` 依赖 `last_visit_date`,而目标 SQL 中没有这个字段。本次必须同步调整前端筛选项和后端查询语义,不能保留错误筛选。 ### 14.3 当前仓库里已有一轮错误起点实现 实施时要以“替换错误模型”为目标,不做双轨兼容。 ## 15. 最终结论 本次 case 的正确完成定义为: 1. `customer` 相关实现整体回到 [数据库设计.md](file:///Users/yuchen/Documents/hairstylist/backend/docs/数据库设计.md#L16-L54) 的字段语义 2. 唯一额外补充 `hairstylist_id` 用于数据隔离 3. 新建、列表、统计三条链路全部改为新模型 4. 前端同步做真实字段适配 5. 旧错误字段模型不再保留 6. 开发完成后同步替换旧文档内容,避免误导后续开发