# PAC 数据库结构评审 — Round 1

> **状态**:草案 · 待评审
> **范围**:本轮覆盖 `platforms` 与 `patients` 两张表,以及用户 / 角色 / 权限模型的对外承诺。
> **目的**:让技术评审能在正式建库之前发现问题,字段一旦上线再改成本就高了。

---

## 总体设计原则(本文档全部决策的前提)

1. **三级租户隔离**。所有业务表都带 `platform_id + tenant_id + clinic_id`,联合索引,查询层强制过滤,跨租户绝不串数据。
   - `platform_id` = 哪个宿主接入了 PAC(friday / 5i5y / dw / demo)
   - `tenant_id` = 宿主自己的客户/组织 id
   - `clinic_id` = 该租户下的具体诊所
2. **PAC 关心"事实",不复制 EMR**。诊断、治疗、预约只存"何时发生了什么大类",不存病历正文 / 处方 / 影像。完整 EMR 永远在宿主侧。
3. **JSON 列只装"易变 + 平台特异"配置**(adapter 配置、规则、URL 模板、画像证据),固定字段还是用列。
4. **字段一旦对外暴露就视为契约**(API path、token 字段名、code 数值、行为语义)。无业务原因不破坏向后兼容。
5. **合规优先**。涉及医疗数据的边界字段(不打扰 / 已故 / 软删除)在表设计阶段就要立柱化,不能塞 JSON。

---

## 一、`platforms` 平台租户注册表

### 1.1 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` | TEXT(UNIQUE) | ✓ | 业务可读标识(`friday`/`5i5y`/`demo`) |
| `name` | TEXT | ✓ | 平台中文名,后台展示 |
| `app_id` | TEXT(UNIQUE) | ✓ | 宿主调换票接口的 appId,格式 `pac_<platformId>_<8位随机>` |
| `app_secret_hash` | TEXT | ✓ | scrypt 哈希(`salt:hash`),原文从不落库 |
| `pull_config` | JSONB | nullable | 当 PAC 拉这个平台时所需配置,见下表 |
| `push_secret_hash` | TEXT | nullable | 接受该平台 push 时验签的 HMAC 共享密钥哈希 |
| `field_mapping` | JSONB | ✓(默认 `{}`) | 字段名映射(host 字段名 → PAC canonical 名) |
| `rules_config` | JSONB | ✓(默认 `{}`) | 召回规则(漏诊阈值 / 回收超时 / 优先级权重) |
| `action_urls` | JSONB | ✓(默认 `{}`) | 动作跳转 URL 模板(VIEW_PATIENT 等) |
| `active` | BOOL | ✓(默认 true) | 软停用开关,false 时换票直接 401 |
| `created_at` / `updated_at` | timestamp | ✓ | 审计 |

**索引**:`UNIQUE(platform_id)`、`UNIQUE(app_id)`(同时是唯一约束 + 等值查找的覆盖索引)

### 1.2 JSON 列内的形状(应用层用 zod 校验)

#### `pull_config` — 仅当 PAC 主动拉该平台时填

```jsonc
{
  "endpoints": {
    "patients":     "https://friday.example.com/api/patients",
    "diagnoses":    "https://friday.example.com/api/diagnoses",
    "treatments":   "https://friday.example.com/api/treatments",
    "appointments": "https://friday.example.com/api/appointments"
  },
  "credentials": {
    // 二选一:
    "loginUrl":  "https://friday.example.com/api/auth/login",  // 服务账号登录
    "username":  "recall-bot",
    "password":  "<encrypted>",
    // OR
    "apiKey":    "<bearer-token>"
  },
  "intervalMinutes": 5
}
```

**为 null 时**:PAC 不会主动拉这个平台(它要么完全靠 push,要么不贡献数据)。

#### `field_mapping` — host 字段名 → PAC canonical 名

```jsonc
{
  "patient":     { "phone": "mobile", "externalId": "pid" },
  "diagnosis":   { "patientExternalId": "patient_pid" },
  "treatment":   { },
  "appointment": { }
}
```

每个 resource 都是可选;留空表示 host 字段名跟 PAC canonical 一致。

#### `rules_config`

```jsonc
{
  "missedThresholdDays": 180,        // 多久未复诊算漏诊
  "excludeCompletedWithinDays": 30,  // 近 N 天完成治疗的不召回
  "priorityWeights": {               // 不同治疗类型的优先级权重
    "implant": 100,
    "rootCanal": 80,
    "filling": 40
  },
  "recycleTimeoutHours": 72,         // 线索超时回收
  "maxContactAttempts": 3            // 最大联系次数(熔断)
}
```

#### `action_urls`

```jsonc
{
  "VIEW_PATIENT":       "https://friday.example.com/patient/{patientId}",
  "CREATE_APPOINTMENT": "https://friday.example.com/appointment/new?patientId={patientId}",
  "VIEW_XRAY":          null  // null = 不支持该动作,前端按钮不渲染
}
```

### 1.3 设计决策

**为什么 `app_secret` 用 scrypt 哈希存,不是 AES 加密**:
- 我们只需要"验证宿主传的对不对",不需要反向读出原文
- scrypt 是单向的,即使 DB 泄漏也拿不到原文
- 创建 / 轮换时返回明文一次,失去就只能再轮换

**为什么 `field_mapping` 是必要的 platform-level 配置**:
- 各宿主对同一概念字段命名不同(mobile / phone / contact_phone),无法纯文档约定
- 这是 platform 表里**唯一**没法砍的"非通用"配置

### 1.4 不加的字段(评审时可讨论)

| 候选字段 | 不加的理由 |
|---|---|
| `region` / `time_zone` | 暂时所有宿主都国内同时区,有需要再扩 |
| `data_retention_days` | 数据留存策略目前全平台统一,等真有差异再立柱 |
| `tier` / `quota` | AI 调用配额按 platform 维度限是合理的,但需要专门的 `platform_quotas` 表(每月重置等) |
| `contact_email` / `contact_phone` | 平台联系人,放后台管理界面的 metadata 表更合适,主表保持精简 |

### 1.5 待讨论问题

1. **app_secret 轮换的 grace 期**:目前轮换是即时失效(老 secret 立刻不能用)。要不要支持"新旧两个 secret 共存 N 天"?好处是宿主可以平滑切换;代价是要存两个哈希 + 复杂的过期逻辑。**建议**:不做,接入方既然能轮换说明代码已经能动,直接换够用。
2. **`pull_config.credentials.password` 加密落库**:目前哈希处理了 appSecret,但 pullConfig 里的密码是**明文 JSON**(因为我们要拿原文去登录宿主)。要不要接 KMS / Vault?**建议**:v1 dev/staging 用环境变量加密,prod 上线前做。
3. **`platforms.deactivate` 后正在跑的 plan 怎么办?** 目前 `active=false` 只阻止后续换票,已签出的 token 仍能用到 2h 过期。要不要 token 黑名单?**建议**:不做,2h 自然过期 + 文档说清楚足够。

---

## 二、`patients` 患者档案表

### 2.1 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT | ✓ | 三级隔离 |
| `external_id` | TEXT | ✓ | 宿主系统里的患者 id,回链用 |
| `name` | TEXT | ✓ | 姓名 |
| `phone` | TEXT | ✓ | 电话 |
| `gender` | TEXT | nullable | 自由文本(各宿主取值不一,不强枚举) |
| `birth_date` | DATE | nullable | 出生日期(算年龄) |
| `preferences` | JSONB | nullable | 沟通偏好(语言/时段/渠道),自由 JSON |
| `do_not_contact` | BOOL | ✓(默认 false) | **不打扰名单**,合规硬要求,一旦 true 永久排除召回池 |
| `do_not_contact_reason` | TEXT | nullable | 不打扰原因(complaint / opt_out / treated_elsewhere / ...) |
| `do_not_contact_at` | timestamp | nullable | 何时打的标 |
| `deceased` | BOOL | ✓(默认 false) | 已故标记,医疗 SaaS 必须支持 |
| `deceased_at` | timestamp | nullable | |
| `status` | TEXT | ✓(默认 `active`) | `active` / `archived`(宿主软删除) / `merged`(合并到 mergedIntoId) |
| `merged_into_id` | UUID | nullable | 当 status=merged 时指向合并目标 |
| `created_at` / `updated_at` | timestamp | ✓ | 审计 |

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — 跨同步 upsert 去重
- `(platform_id, tenant_id, clinic_id)` — 按诊所聚合查询
- `(phone)` — 按电话查重 / 客服快捷查找
- `(platform_id, tenant_id, clinic_id, status, do_not_contact, deceased)` — **召回池热查询路径**

### 2.2 设计决策

**为什么 `do_not_contact` / `deceased` 必须立柱(不能塞 preferences JSON)**:
- 是召回池的硬过滤条件(每次查询都用),JSON 子字段查询慢且无法索引
- 是合规边界,字段名拼写错 = 该排除的没排除 = 事故。强类型让编译期发现错
- 单独存便于审计:谁、何时、什么原因打的标,DBA 工具可见

**为什么砍掉了曾考虑的 `last_contacted_at` / `contact_attempts`**:
- 这两个数据可以从 `plan_executions` 实时聚合(`MAX(started_at)` / `COUNT(*)`)
- 冗余字段 = 双写 + 漂移风险 + 热行竞争,违反"single source of truth"
- 召回池查询用 `NOT EXISTS (SELECT 1 FROM plan_executions ...)` + 索引足够
- 真到几百万 PlanExecution 跑不动再考虑物化视图,**不在 v1 提前优化**

**`preferences` 为什么用 JSONB 而不是立柱**:
- 沟通偏好的子项还在演进中(语言 / 时段 / 渠道 / 节假日规则 ...),立柱会反复改 schema
- 这些字段不参与召回池筛选(只在生成话术时被 agent 读),不需要索引
- 真有高频筛选需求的字段(如"夜间不要打"),将来按需拆出来

**`gender` 为什么不强枚举**:
- 各宿主取值不一致(`M`/`F`、`男`/`女`、`male`/`female`、还有 `unknown` / `other`)
- 强枚举会在 ingest 阶段拒收宿主真实数据,不是好的契约
- 业务侧用到性别时再做归一化(generally only at agent prompt assembly time)

### 2.3 不加的字段(评审时可讨论)

| 候选字段 | 不加的理由 |
|---|---|
| **身份证号** | PIPL 高敏字段,host 自己管。PAC 用 `external_id` 回链就够。**任何实现都要 review 是否真有必要** |
| 详细地址 / 街道 | 召回业务用不上,泄漏代价大;诊所定位由 `clinic_id` 覆盖 |
| 紧急联系人 / 家属 | 业务深度太具体,host EMR 体系里更合适 |
| 婚姻 / 民族 / 收入 / 职业 | 召回算法不该看这些(歧视风险),合规上也不应留 |
| 既往史 / 过敏史 摘要 | 由 `MedicalSummaryAgent` 即时生成,落到 `PlanSummary` 表(版本化、有审计),不进 patient 主档 |
| `email` / `wechat_id` | 当前业务只走电话回访;真有多渠道触达再加(放 preferences 也可) |
| `last_contacted_at` / `contact_attempts` | 见 2.2,从 plan_executions 聚合即可 |
| `vip_level` / `customer_value_score` | 这是 Persona 的特征(value description + numericScore),不是 patient 主档字段 |

### 2.4 EMR 在 PAC 的边界(评审时务必对齐)

宿主 EMR 通常含:主诉 / 现病史 / 体格检查 / 诊断 / 处方 / 影像 / 病程 / 知情同意书 等。

PAC **不复制 EMR**,只存"召回算法需要的事实子集",通过三张快照表表达:

| PAC 表 | 存什么 | 不存什么 |
|---|---|---|
| `Diagnosis` | 患者被诊断了什么(代码 + 名称 + 时间) | 主诉 / 现病史 / 鉴别诊断 |
| `Treatment` | 患者接受了什么大类治疗(项目 + 状态 + 完成时间) | 器械 / 用药剂量 / 术后细节 |
| `Appointment` | 患者有没有预约 / 到诊(时间 + 状态) | 接诊医生备注 / 病程 |

**举个具体对照(同一次复诊):**

| 内容 | 宿主 EMR | PAC |
|---|---|---|
| 主诉 | "右下后牙冷热痛 1 周" | (不存) |
| 诊断 | "36 牙不可复性牙髓炎" + 详细描述 + 鉴别 | `Diagnosis: { name:"不可复性牙髓炎", code:"K04.0", diagnosedAt:... }` |
| 治疗 | 详细操作步骤 + 用药 + 充填材料 | `Treatment: { name:"根管治疗", status:"completed", completedAt:... }` |
| 处方 | 布洛芬 0.4g tid × 3 天 | (不存) |
| 影像 | 4 张根尖片 | (不存) |
| 复诊建议 | "1 周后复诊评估" | (宿主推 Appointment 时落到 PAC) |

**EMR 详细内容怎么消费**:
1. 客服打电话前看完整病历 → 前端按钮通过 `actionUrls.VIEW_PATIENT` 跳到宿主 EMR 页面
2. AI 生成话术需要病历摘要 → `MedicalSummaryAgent` 即时调宿主 API 拉,只把摘要放 `AgentInvocation.input_snapshot` 审计,**不写患者主档**
3. 业务非要持久化某些 EMR 摘要 → 加 `MedicalRecord` 表(限制为已脱敏摘要),v1 不做

### 2.5 待讨论问题

1. **手机号变更怎么处理**:宿主修改了手机号,PAC 要历史保留吗?目前每次同步直接 upsert 覆盖,旧号丢失。**建议**:不主动留历史(隐私 + 复杂度),如真有客服争议查询需求,从 `PatientFactEvent` 的 `patient_updated` 事件里能找到。
2. **同名同手机的"多档案"问题**:同一患者在两家诊所都有档案,external_id 不同。PAC 要不要做"跨诊所合并"?**建议**:v1 不做,各 clinic_id 独立。需要时用 `mergedIntoId` 字段做手工合并。
3. **`do_not_contact` 的"过期解除"**:打了不打扰的患者,过 1 年要不要自动解除?**建议**:不做。打标的人要明确,解除也要明确动作,不要时间隐式触发。
4. **`status='archived'` 后历史 plan 怎么处理**:目前 PlanEngine 重算会 supersede 所有 active plan;但已经 completed 的 plan 不动。要保留 archived 患者的历史 task 数据吗?**建议**:保留,审计需要。

---

## 三、用户 / 角色 / 权限模型

### 3.1 PAC **不建用户表**

**理由**:
- 用户体系在宿主侧(Friday / 5i5y 自己管),PAC 不重复一份
- 每次换票宿主把 `userId / tenantId / role / clinicIds` 传过来,签进 JWT
- PAC 数据库里只有"哪个 userId 在哪个 plan 上做过什么"(`plan_executions.operator_user_id`),**不存用户档案**
- 后台管理员 / 运维:同样走宿主登录(以 admin role 换票)。PAC 不维护独立的"PAC 自有员工"

**好处**:
- 用户离职 / 改密码 / 改名 → 全在宿主侧处理,PAC 0 维护
- 不会出现"PAC 用户表跟宿主用户表不同步"这类经典坑

**代价**:
- 每次审计"这条 plan 是谁操作的"要去宿主反查 user 详情(只有 userId 字符串)
- PAC 自身没有"登录界面",必须通过宿主带 code 进来

### 3.2 三个固定角色(代码常量,不入库)

定义在 [`packages/types/src/enums/index.ts`](../packages/types/src/enums/index.ts) 的 `UserRole`:

| Role | 业务定位 | 典型默认权限 |
|---|---|---|
| `staff` | 一线客服(执行回访) | 看自己的线索 / 执行通话 / 拿话术 / 看患者档案 |
| `leader` | 主管 / 班长 | staff 全部 + 看全员线索 / 分配 / 回收 / 看统计 |
| `admin` | 后台管理员 | leader 全部 + 平台注册 / 凭据轮换 |

**为什么固定 3 个**:
- 集团已经定下基本结构,不需要让每个宿主再自定义
- 自定义权限会带来"这个组叫什么"的命名混乱,加大对接成本
- PAC 是组件,不是 IAM

### 3.3 权限模型

定义在同一文件的 `Permission` 常量(12 个):

```
plan:view_own / plan:view_all / plan:assign / plan:recycle / plan:execute
patient:view / persona:view
agent:invoke / fact:ingest
tenant:config:view / tenant:config:update / stats:view / platform:manage
```

**Role → Permission 映射**(`ROLE_PERMISSIONS` 常量):
- `staff`:`plan:view_own` / `plan:execute` / `patient:view` / `persona:view` / `agent:invoke`
- `leader`:staff 全部 + `plan:view_all` / `plan:assign` / `plan:recycle` / `stats:view`
- `admin`:所有(包括 `platform:manage`)

**换票流程**:
1. 宿主调 `POST /pac/v1/auth/token`,body 里 `user.role` 带 `staff/leader/admin`
2. PAC 服务端用 `ROLE_PERMISSIONS[role]` 展开成完整 permission 列表
3. 把 permission 列表签进 JWT payload(client 不能改)
4. 后续所有 API 调用通过 `@RequirePermission('plan:assign')` 装饰器 + `PermissionsGuard` 校验
5. 前端读 token payload 的 `permissions[]`,通过 `<Can perm={...}>` 组件控制 UI 显示

**双层守门**(标准做法):
- 前端按权限隐藏 UI,体验好(不让用户点了再 403)
- 后端在路由层硬验,安全可靠(前端被绕过也挡得住)

### 3.4 现阶段不做自定义

| 维度 | 当前 | v1.5 / v2 演进 |
|---|---|---|
| Role 自定义 | 不支持,固定 3 个 | 加 `roles` 表(platform_id + role_key + permissions[]),宿主管理后台可改 |
| Permission 自定义 | 不支持,代码常量 | 同上,permissions 改成可在 DB 里增删 |
| 用户级权限覆盖 | 不支持 | 加 `user_permissions` 表,允许"某用户额外加 stats:view" |
| 行级权限(ABAC) | 不支持 | 加 policy 表 + 表达式 / Cedar / OPA 接入 |

**v1 决定**:不做以上任何一项。**理由**:加了之后每个宿主接入都要先想"我们要不要自定义",对接成本反升。等真有具体需求再加。

### 3.5 一些边界事实(评审时容易问)

1. **同一个 user 在不同 clinic 的权限不同**? — 不支持。`clinicIds` 是 user 维度的(他能管哪几家诊所),不是 role 维度的。如果业务需要"在 A 店是 staff、在 B 店是 leader",换票时按场景换不同 token。
2. **token 过期后自动重新登录** — 走 refresh token(2h access + 7d refresh);refresh 失败 = iframe 整个重载,宿主重新换 code。
3. **强制下线**(用户被禁用) — 当前不支持(token 是无状态的)。**临时方案**:短时效(2h)。**真要即时**:加 token 黑名单 Redis(签发时记 jti,使用时查存活)。
4. **审计日志**(谁操作了什么) — `plan_executions.operator_user_id` 已记;`agent_invocations` 也有完整审计;但**没有"谁查询了谁的患者列表"的访问日志**。需要时加 `access_logs` 表。

---

## 四、评审需要确认的问题清单(请技术评审组逐项给意见)

### platforms

- [ ] `app_secret` 不支持 grace 期(轮换即时生效),OK 吗?
- [ ] `pull_config.credentials.password` 在 v1 是明文 JSON,prod 前接 KMS 这个时间点 OK 吗?
- [ ] `field_mapping` 由 PAC 这边维护,宿主接入时给一份样例响应让 PAC 配,OK 吗?

### patients

- [ ] **EMR 边界**:PAC 只存 Diagnosis + Treatment + Appointment 三张快照,病历正文走宿主跳转,这个边界对齐吗?
- [ ] `do_not_contact` 不做时间过期,需要解除必须显式动作,OK 吗?
- [ ] `gender` 不强枚举,在 ingest 时不归一化,OK 吗?
- [ ] 同一患者跨诊所的"多档案合并"v1 不做,OK 吗?
- [ ] 不做手机号变更历史,旧号丢失只能从 `patient_fact_events` 找,OK 吗?
- [ ] 身份证号 / 详细地址 不存,如果业务方提需求,统一拒绝,OK 吗?

### 用户 / 角色 / 权限

- [ ] PAC 不建用户表,所有用户身份在宿主侧 — 集团内对齐过吗?
- [ ] 三个固定 role(staff / leader / admin)够不够覆盖一线 + 主管 + 管理员?
- [ ] 不做自定义角色 / 自定义权限,等 v1.5 再加,接入方能接受吗?
- [ ] 强制下线不做(只靠 2h 短时效),合规上 OK 吗?

### 通用

- [ ] JSON 列里的字段(`pull_config` / `rules_config` / `action_urls` / `preferences` / `evidence`)的 zod schema 在应用层校验,DB 不强制约束 — 这个权衡 OK 吗?
- [ ] 索引策略:每张业务表都有 `(platform_id, tenant_id, clinic_id)` 联合索引,加上业务热路径的二级索引。是否还有遗漏?

---

## 五、变更追踪

| 时间 | 变更 | 决议 |
|---|---|---|
| 2026-05-08 | Patient 加 `do_not_contact` / `deceased` / `status` / `merged_into_id` 四组字段 + `(platform, tenant, clinic, status, dnc, deceased)` 复合索引 | 已落地 (migration `patient_compliance_fields`) |
| 2026-05-08 | Patient 砍掉 `last_contacted_at` / `contact_attempts`,改用 `plan_executions` 实时聚合 | 已决定不做 |
| 2026-05-07 | `platforms` 砍掉 `pull_adapter` / `push_adapter` / `adapter_config`,改 `pull_config` + `push_secret_hash` + `field_mapping` 立柱 | 已落地 |
| 2026-05-07 | 加 admin 接口集(注册 / 列表 / 改 / 轮换 / 停用) + Permission `platform:manage` | 已落地 |

> 后续每轮评审完一张表,在这里追加变更记录,保留审计链。
