# 架构决策：StoreCustomer 事实时间线

> 日期：2026-08-02
> 状态：Accepted / Code Complete

## 1. 决策

客户详情时间线以 canonical `StoreCustomer.id` 为唯一门店关系主键，以已持久化的 `BusinessEvent` 为事实源。它是只读投影，不新建可编辑“动态”表，也不从页面点击、当前对象状态或日志猜测历史。

手机号留资投影与账号主档合并时，旧投影软删除并写入 `merged_into_store_customer_id`，既有 BusinessEvent 继续引用旧 ID 且不可改写。时间线解析 canonical 后同时查询它和全部同店别名的事件，因此合并前后的客户事实保持连续。别名只允许单层指向同店、有效且未再合并的 canonical；生产预检拒绝活动别名、自引用、跨店、缺失或链式目标。

`GET /api/admin/service-customers/timeline` 只接受 `storeCustomerId/page/pageSize`。门店范围从 session 派生；`current.storeId + storeCustomerId` 不匹配时按不存在返回 404，避免枚举他店客户。

## 2. 展示口径

- 按 `occurredAt DESC, id DESC` 对 canonical 与别名事件统一稳定分页；同一时刻用数据库事件 ID 决定顺序。
- 展示预约创建/状态、开始/完成服务、报告提交/确认发送/首次打开/再次打开、回访意向等已经落库的事实。
- 批量补齐预约、报告、留资和 FollowUpTask 的当前低敏摘要与详情跳转 ID；对象已删除时仍保留事件标题和发生时间，不伪造摘要。
- Admin 客户抽屉可跳到预约详情、报告列表和回访任务池；后续已按独立决策落地 FollowUpTask 与真实再次预约归因，时间线只读取其不可变事件，不承担任务状态写入。

## 3. 隐私与数据边界

允许返回：客户称呼、脱敏手机号、宠物名、服务类型、预约时间、当前业务状态、发送渠道、提醒日期、操作人显示名/角色。

禁止返回：手机号原文、预约/报告备注、图片/视频 URL、报告或邀请 token/hash、openid/unionid、IP、请求体和原始 `metadata_json`。服务端只解析状态变化、发送渠道、重复提交、回访日期/结果/次数和预约来源等受控低敏 metadata 字段。

时间线不承载会员等级、余额、次卡、积分、营销标签或自由文本跟进记录。这些能力若进入后续阶段，必须使用独立对象与状态机，不得改写历史事实。

## 4. 验收

- 同店 boss/staff 可查；customer 禁止；跨店返回 404。
- 分页上限 100，顺序稳定，返回 `total/page/pageSize/hasMore`。
- 预约、报告、回访聚合 ID 相同时仍按 `aggregateType` 精确解析。
- canonical ID 与已合并别名 ID 均返回同一 canonical 客户摘要，并包含合并前后的全部事件。
- system/public 事件无操作人时正常返回。
- 自动化反例确认响应不包含完整手机号、token、备注或媒体字段。
- 本体校验与代码端点漂移均为 0；Backend 全测与 Admin production build 通过。

数据库迁移：`backend/db/migrations/20260802_create_store_customer_timeline.sql`，在门店开通迁移后第七个执行；末尾两项别名不变量必须为 0。
