ソースを参照

docs: add customer module create list summary spec

ChenYu 4 ヶ月 前
親
コミット
aeff954b51

+ 556 - 0
docs/superpowers/specs/2026-06-28-customer-module-create-list-summary-design.md

@@ -0,0 +1,556 @@
+# 顾客模块设计(新建 / 列表 / 统计)
+
+## 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