# 架构决策：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. 非目标

本阶段不引入会员等级、积分、储值、次卡、标签群发、跨店共享或客户合并后台。这些需要独立的权限、资产和审计模型。
