# 架构决策:StoreCustomer 门店客户主档 > 日期:2026-08-01 > > 状态:已采纳,待生产数据迁移 > > 范围:Backend Core、Report Media Backend、Store Admin、Data Model ## 1. 决策背景 原「服务客户」列表每次请求时才从 User、Pet、Appointment、Report 和 ReportLead 拼装。同一个客户在不同门店没有稳定关系 ID,留资手机号与后续 customer 账号也可能生成两条投影,无法可靠承载客户时间线、回访任务和再次预约。 ## 2. 冻结模型 `StoreCustomer` 是「门店与客户的关系主档」,不是全局账号,也不是会员资产。 | 字段 | 语义 | |---|---| | `id` | 本店客户稳定 ID,后续时间线/回访任务的关联键 | | `store_id` | 门店数据范围 | | `customer_user_id` | 全局 customer 账号;可空,便于先承接留资 | | `phone` | 本店最后确认联系号;后台 API 默认脱敏 | | `display_name` | 门店视角称呼 | | `source` | 首次来源:`customer_booking` / `assisted_booking` / `report_lead` | | `first_contact_at` / `last_contact_at` | 本店关系首次/最近触达时间 | | `merged_into_store_customer_id` | 软删除投影并入的同店 canonical 主档;保留不可变事件归属连续性 | 同店分别对 `(store_id, customer_user_id)` 和 `(store_id, phone)` 加唯一约束。`customer_user_id` 为空时可先以手机号建档;后续识别到 customer 账号时绑定或合并,禁止同手机号指向两个 customer。 ## 3. 写入入口 - 宠主自助预约:建档来源 `customer_booking`。 - boss/staff 代客预约:建档来源 `assisted_booking`。 - 报告留资:建档来源 `report_lead`;若手机号已对应 customer,直接绑定。 - 后续触达只更新 `last_contact_at`,不覆盖可靠的首次来源。 ## 4. 读模型与口径 `GET /api/admin/service-customers` 以 `StoreCustomer` 为列表入口,再聚合宠物、预约、报告和留资,返回稳定 `storeCustomerId`。 - `lastVisitAt` 只计算已开始/已完成的历史服务;未来预约不算到店。 - `lastReportId`、`leadStatus`、`pets` 仍从事实表实时读取,避免将易变经营指标重复写入主档。 - 返回 `phoneMasked`,不返回明文 `phone`。 ## 5. 历史迁移 按顺序执行: 1. `backend/db/migrations/20260801_split_service_identity.sql` 2. `backend/db/migrations/20260801_create_store_customer.sql` 第二个脚本先从已确定客户归属的预约建档,再合并报告留资。末尾三个验证计数必须全部为 0 才可发布新后端。 事实时间线阶段追加执行 `backend/db/migrations/20260802_create_store_customer_timeline.sql`:它只增加合并别名指针与索引,不改写既有 BusinessEvent。迁移末尾两个别名验证计数必须全部为 0。 ## 6. 非目标 本阶段不引入会员等级、积分、储值、次卡、标签群发、跨店共享或客户合并后台。这些需要独立的权限、资产和审计模型。