# 顾客模块设计(新建 / 列表 / 统计) ## 1. 背景 当前项目已经具备: - 发型师登录闭环 - 最小工作台入口 - GCS 文件上传能力 顾客模块目前在前端已经存在: - 新建顾客页面 - 顾客列表页面 - 顾客统计卡片 但后端 `customers` 模块仍是占位实现,数据库也缺少真正承载顾客档案主数据的 `customer` 主表。 本次 case 的目标是先完成顾客模块的最小业务闭环: - 新建顾客 - 顾客分页列表 + 条件查询 - 顾客统计接口 顾客详情页明确留到下一个 case。 ## 2. 范围 ### 本次实现 1. 新建顾客并写入数据库 2. 头像可选;未上传时使用默认头像 URL 3. 顾客列表分页查询 4. 顾客列表条件筛选 5. 顾客顶部统计接口 6. 前端顾客新建页与列表页对接真实后端接口 ### 本次不实现 1. 顾客详情页 2. 编辑顾客 3. 顾客施术记录详情聚合 4. 顾客提醒发送 5. 顾客 AI 模拟详情页聚合 6. 顾客历史记录接口 ## 3. 设计原则 ### 3.1 数据对齐策略 本次采用: `以现有数据库设计为准,前端做适配` 解释: - 数据库结构优先贴近现有 SQL 文档与既有业务表关系 - 前端当前表单字段不直接决定数据库列名 - 后端负责做字段名映射与值规范化 ### 3.2 头像策略 - 顾客头像不是必填 - 前端上传头像时,复用已完成的存储能力 - 上传 `type` 固定使用:`avatar` - 若前端未上传头像,则后端在创建顾客时自动写入默认头像 URL: `https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png` ### 3.3 分页策略 - 顾客列表采用页码分页 - 查询参数使用:`page` + `pageSize` ### 3.4 查询语义 - 搜索词:模糊查询 - 到店状态:日期范围查询 - 跟进:按业务枚举查询 - 发型:按发长精确匹配 ### 3.5 统计语义 - `今天到店`:按计划到店日判断,即 `nextVisit` - `需跟进`:按 `followStatus` 判断 ## 4. 数据模型设计 ### 4.1 新增主表 本次新增 `customer` 主表,作为顾客档案主数据的真实来源。 该表用于承接: - 顾客基本资料 - 顾客联系信息 - 咨询与发质信息 - 跟进信息 并作为以下表的逻辑上游主表: - `customer_treatment_record.customer_id` - `ai_hair_simulation.customer_id` 本次不强制要求一次性补完数据库外键约束,但 Prisma 与业务语义上必须明确该主从关系。 ### 4.2 建议字段 建议字段如下: - `id` - `hairstylist_id` - `name` - `kana` - `phone` - `email` - `birthday` - `avatar_url` - `contact_type` - `line_status` - `last_visit_date` - `next_visit_date` - `visit_cycle` - `hair_length` - `hair_type` - `hair_condition` - `concern` - `desired_image` - `formula_note` - `allergy_note` - `private_note` - `follow_status` - `follow_timing` - `follow_message` - `create_time` - `update_time` - `is_deleted` ### 4.3 索引 本次 migration 只新增 `customer` 表及必要索引,不修改既有旧表结构。 建议索引: - `idx_hairstylist_id` - `idx_phone` - `idx_next_visit_date` - `idx_follow_status` - `idx_hair_length` - `idx_is_deleted` - 组合索引:`(hairstylist_id, is_deleted)` 可选唯一约束: - `(hairstylist_id, phone, is_deleted)` 说明: - 若允许同一个发型师名下同手机号只存在一条未删除顾客记录,则增加唯一约束 - 若当前业务暂不确认,先不加唯一约束,避免阻塞本次实现 本次默认先不强制该唯一约束,避免因历史脏数据或录入场景不明确导致创建失败。 ## 5. 值规范化设计 后端负责把前端表单值规范化后再入库。 ### 5.1 发长 - `Short` -> `SHORT` - `Medium` -> `MEDIUM` - `Long` -> `LONG` ### 5.2 联系方式 - `LINE` -> `LINE` - `Phone` -> `PHONE` - `Email` -> `EMAIL` ### 5.3 LINE 关联状态 - `已关联` / `已連携` -> `LINKED` - `未关联` / `未連携` -> `UNLINKED` - `邀请已发送` -> `INVITED` - `保留` -> `KEEP` ### 5.4 跟进状态 本次采用规范枚举存储: - `NONE` - `NEED_FOLLOW_UP` - `SENT` 若前端传入的是展示文案,后端负责映射成上述规范值。 ## 6. 前后端字段映射 前端字段与数据库字段映射如下: - `image` -> `avatar_url` - `contact` -> `contact_type` - `lineStatus` -> `line_status` - `lastVisit` -> `last_visit_date` - `nextVisit` -> `next_visit_date` - `hairLength` -> `hair_length` - `hairType` -> `hair_type` - `hairCondition` -> `hair_condition` - `desiredImage` -> `desired_image` - `formulaNote` -> `formula_note` - `allergyNote` -> `allergy_note` - `privateNote` -> `private_note` - `followStatus` -> `follow_status` - `followTiming` -> `follow_timing` - `followMessage` -> `follow_message` 说明: - 前端提交结构尽量保持现状 - 后端 DTO / mapper 负责做字段映射与值规范化 - 返回给前端时再映射回前端当前约定字段 ## 7. API 设计 ### 7.1 新建顾客 `POST /api/v1/customers` 请求体: ```ts type CreateCustomerRequest = { name: string phone: string kana?: string email?: string birthday?: string image?: string contact?: string lineStatus?: string lastVisit?: string nextVisit?: string visitCycle?: string hairLength?: string hairType?: string hairCondition?: string concern?: string desiredImage?: string formulaNote?: string allergyNote?: string privateNote?: string followStatus?: string followTiming?: string followMessage?: string } ``` 规则: - `name` 必填 - `phone` 必填 - `image` 非必填 - 若 `image` 未传,则后端自动写入默认头像 URL - 头像上传由前端先调用存储上传接口完成,再将上传结果 URL 传给顾客接口 返回: - 返回创建后的顾客对象 - `id` 对前端按字符串返回 - 日期字段统一返回 `YYYY-MM-DD` ### 7.2 顾客分页列表 `GET /api/v1/customers` 查询参数: ```ts type ListCustomersQuery = { page?: number pageSize?: number q?: string visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK' | 'OVER_30_DAYS_NO_VISIT' followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'SENT' hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG' } ``` 返回: ```ts type ListCustomersResponse = { items: CustomerListItem[] total: number page: number pageSize: number } ``` 约束: - `total` 表示当前筛选条件下的总条数 - 用于前端展示“搜索结果 X 件” - `items` 只返回列表页面当前真正需要的字段 ### 7.3 顾客统计 `GET /api/v1/customers/summary` 返回: ```ts type CustomerSummaryResponse = { total: number today: number followUp: number simulated: number } ``` 说明: - 统计接口归属顾客域内,不继续使用 `/stylist/dashboard/customer-summary` - 前端后续改为调用顾客域内接口 ## 8. 列表项设计 本次列表项只返回当前前端真正用到的字段,不提前把详情页字段全部塞入列表返回。 建议列表项字段: - `id` - `name` - `phone` - `image` - `lastVisit` - `hairLength` - `concern` - `followStatus` ## 9. 查询规则 ### 9.1 搜索词 `q` 模糊查询以下字段: - `name` - `phone` - `concern` - `private_note` ### 9.2 到店状态 `visitStatus` #### `ALL` 不加筛选条件 #### `TODAY` `next_visit_date = 今天` #### `THIS_WEEK` `next_visit_date` 落在本周范围内 #### `OVER_30_DAYS_NO_VISIT` `last_visit_date < 今天 - 30天` ### 9.3 跟进状态 `followStatus` #### `ALL` 不加筛选条件 #### `NEED_FOLLOW_UP` `follow_status = NEED_FOLLOW_UP` #### `SENT` `follow_status = SENT` ### 9.4 发长 `hairLength` #### `ALL` 不加筛选条件 #### `SHORT | MEDIUM | LONG` 精确匹配 `hair_length` ## 10. 统计规则 所有统计均只统计: - 当前登录发型师名下数据 - `is_deleted = 0` ### 10.1 顾客总数 统计当前发型师名下未删除顾客总数 ### 10.2 今天到店 统计: - `next_visit_date = 今天` ### 10.3 需跟进 统计: - `follow_status = NEED_FOLLOW_UP` ### 10.4 已模拟 统计规则: - 在 `ai_hair_simulation` 中存在记录 - `customer_id` 非空 - 按 `customer_id` 去重 - 仅统计当前发型师名下顾客 实现建议: - 以 `customer` 为主表 - 关联 `ai_hair_simulation` - 对顾客维度去重计数 避免把其他发型师的数据混入当前统计结果。 ## 11. 鉴权与数据隔离 本次所有顾客接口都必须按当前登录发型师进行数据隔离。 包括: - 新建顾客 - 顾客列表 - 顾客统计 后端从 JWT 中读取当前用户 `sub`,作为 `hairstylist_id`。 规则: - 创建时写入 `hairstylist_id` - 查询时只能查当前发型师自己的顾客 - 统计时只能统计当前发型师自己的顾客 ## 12. 后端模块边界 本次 `CustomersService` 只实现: - `create()` - `list()` - `getSummary()` 本次不实现: - `getById()` - `update()` - `records / reminders / detail 聚合` 说明: - 顾客详情页是下一个 case - 本次只覆盖当前前端真正依赖的最小闭环 ## 13. 前端改动边界 本次前端只做必要适配: 1. 顾客头像上传时,调用存储上传接口并使用 `type=avatar` 2. 顾客列表接口补充: - `page` - `pageSize` - `visitStatus` - `followStatus` - `hairLength` 3. 顶部统计接口改为调用 `/customers/summary` 4. 分页结构对接真实 `total` 本次不强做复杂分页交互设计,只保留当前页面所需的最小可用能力。 ## 14. 测试设计 ### 14.1 单元测试 至少覆盖: 1. 新建顾客时未传头像,自动写默认头像 URL 2. 新建顾客时写入当前 `hairstylist_id` 3. 列表筛选参数可正确转换为查询条件 4. 统计逻辑符合定义 ### 14.2 e2e 测试 至少覆盖: 1. 未登录访问顾客接口返回 `401` 2. 新建顾客成功 3. 顾客列表分页成功 4. 顾客列表筛选成功 5. 顾客统计接口成功 ### 14.3 联调验收 完成标准: 1. 可从前端“新建顾客”页面真实创建顾客并写入数据库 2. 未上传头像时,数据库保存默认头像 URL 3. 顾客列表支持真实分页与筛选 4. 顶部四个统计值来自真实数据库 5. “搜索结果 X 件”显示当前筛选后的真实总数 6. 全链路不使用 mock 成功结果 ## 15. 风险与约束 ### 15.1 前端现有列表筛选逻辑 当前前端 `CustomerListPage` 里仍有部分本地过滤逻辑。 本次实现时需要收敛为: - 优先由后端执行筛选 - 前端不再依赖本地 mock 语义推导过滤结果 ### 15.2 枚举展示文案问题 当前前端页面使用的是中文 / 日文展示文案。 本次实现时必须保证: - 数据库存规范值 - 前端显示文案通过映射层转换 不能把多语言文案直接写入数据库作为业务值。 ### 15.3 详情页延后 本次不得为了“顺手支持详情页”而扩展实现范围。 即使 `getById()` 和 `update()` 当前接口已存在占位,也不在本次 case 中完成。 ## 16. 最终结论 本次 case 的完成定义为: 1. 新增 `customer` 主表与 Prisma model 2. 打通顾客新建 3. 打通顾客分页列表与筛选 4. 打通顾客 summary 统计 5. 前端只做必要适配 6. 顾客详情页留给下一个 case