# PAC 宿主对接契约 v0

> **版本**:v0(草案,待评审)
> **对象**:接入 PAC 的宿主开发团队(5i5ya / Friday / 400 / DW / 第三方)
> **状态**:🟡 草案,W2 末发出,W3-W4 联调收敛后发 v1.0
> **维护**:PAC 团队(luoqi);跨团队对接协调统一通过 CTO(于总)

---

## 目录

- [1. 概览](#1-概览)
- [2. 鉴权](#2-鉴权)
- [3. 数据接入 — Pull / Cold-import(批量数据通道)](#3-数据接入--pull--cold-import批量数据通道)
- [4. Push 路径(宿主主动推事件)](#4-push-路径宿主主动推事件)
- [5. PAC 开放 REST API](#5-pac-开放-rest-api)
- [6. 用户名解析接口(host → PAC 出站点查)](#6-用户名解析接口)
- [7. AssemblerEngine — host 数据投影到 PAC canonical](#7-assemblerengine--host-数据投影到-pac-canonical)
- [8. Action URLs](#8-action-urls)
- [9. PAC Outbound Webhook(可选 / 嵌 iframe 时考虑)](#9-pac-outbound-webhook)
- [10. 接入流程 + Checklist](#10-接入流程--checklist)

---

# 1. 概览

## 1.1 PAC 是什么

**PAC(Patient Analyze Center)= 集团级"召回算法大脑"**。
接收宿主侧的患者事实数据,自动评价患者画像,生成召回任务清单 + AI 话术 + 一页摘要,
让客服每个电话打在刀刃上。

### PAC 做的

- 接收患者事实(治疗 / 预约 / 缴费 / 影像 / 病历元数据)
- 自动评价患者(画像 / 风险 / 价值)
- 生成召回名单 + 优先级 + 召回原因
- AI 生成参考话术 + 一页患者摘要
- 接收执行结果回写(客服打了之后填的结果)
- 对外输出集团级患者画像数据(供宿主多场景复用)

### PAC 不做的(留给宿主)

- 替代宿主已有业务系统(5i5ya / Friday)
- 控制企微 / 外呼 / SCRM 等触达通道
- 持有完整病历原文(EMR 在医生侧 / 临时借用通过 inputSnapshot)
- 主权影像 AI / 影像诊断(交给专业医疗 AI)
- 直连患者(所有动作通过宿主中转)

## 1.2 三种数据通道

```
┌─ PAC 接入 ────────────────────────────────────────────────┐
│                                                             │
│  Pull(PAC → host)  ──→  Host 资源 API                    │
│      用途:批量拉资源 / 单患者刷新                          │
│      触发:cron 周期 + 单患者按需                           │
│      数据形态:host 返回资源对象                 │
│                                                             │
│  Push(host → PAC)  ──→  POST /pac/v1/push/events          │
│      用途:实时事件流(可选,宿主主动接入)                │
│      触发:host 业务事件发生                                │
│      数据形态:transaction-shaped(action + subject + ...)  │
│      安全:HMAC 签名验证                                    │
│                                                             │
│  开放 REST API     ──→  /pac/v1/{plans,patients,...}      │
│      用途:host 前端 / 后端调用 PAC 业务能力                │
│      调用方:PAC 前端 + host 自有前端 / 后端 都可          │
│      鉴权:Bearer JWT(换票流程)                          │
│                                                             │
└────────────────────────────────────────────────────────────┘

附加:host 反向提供
   ┌─ host POST /users:resolve(PAC 调 host)                │
   │   用途:userId → name 解析,token 字典覆盖不到时兜底     │
   └─────────────────────────────────────────────────────────┘
```

**接入策略**:
- **Pull 必接**(主流路径,host 提供资源 endpoint)
- **Push 可选**(宿主有事件总线 / 想实时推 → 接;否则 pull 周期覆盖)
- **PAC 开放 API 必接**(host 前端要展示召回 / Plan / 执行回写)
- **users:resolve 推荐接**(token 字典覆盖不到的 userId 兜底解析)

## 1.3 角色定义

PAC 视角下的概念,跟宿主既有概念对应:

| PAC 概念 | 含义 | 来源 |
|---|---|---|
| `host` | 接入的宿主主体(friday / 5i5ya / 400 中心 / dw) | PAC admin 注册 |
| `tenant` | 宿主侧的客户 / 集团 id | host token 传 |
| `clinic` | 宿主侧的诊所 id | host token 传 + 各事件携带 |
| `user` | 客服 / 主管 / 管理员 (PAC 框架固定 3 role) | host token 传 |

**PAC 不维护 `tenant` / `clinic` / `user` 主数据表** — 所有这些 id 都是 host 提供的字符串,PAC 信任 host 的命名空间。

**同一物理集团接两个宿主** → PAC 视角是 2 个独立 tenant(by-host isolation),数据不串。

## 1.4 接入流程总览图

```mermaid
sequenceDiagram
  participant Host as 宿主(5i5ya/Friday/...)
  participant PAC

  Note over Host, PAC: ─── Phase 1: 注册接入 ───
  Host->>PAC: 提供接入信息(联系人 / 试点 clinic / 端点 URL / 字段命名 / 鉴权方式)
  PAC->>PAC: PAC 团队跑 CLI(pnpm pac:host create)<br/>录 host(host 不直接接触)
  PAC-->>Host: 回发 appId + appSecret + pushSecret(可选)<br/>plaintext 一次性

  Note over Host, PAC: ─── Phase 2: 联调 ───
  PAC->>Host: pull 测试(逐资源验证 endpoint 可达 + 字段映射正确)
  Host->>PAC: push 测试(若启用,推几条样本 event)
  PAC-->>Host: 反馈字段缺失 / 格式问题(若有)

  Note over Host, PAC: ─── Phase 3: 灰度 ───
  Host->>PAC: 1 家试点门诊数据冷启(走 §3.7 cold-import)
  PAC->>PAC: 跑通 Persona / Plan 流水线
  PAC-->>Host: 在 host UI / PAC iframe 看召回任务

  Note over Host, PAC: ─── Phase 4: 全量接入 ───
  Host->>PAC: 全部试点门诊接入
  PAC->>Host: 周期 pull + push 实时(若启用)
```

**典型时长**:Phase 1-2 约 1-2 周;Phase 3-4 约 2-4 周。瓶颈通常在 host 侧 API 就绪度。

## 1.5 宿主侧需要做的事(接入清单)

> 这份文档其余章节都是细节,先看这张表知道**宿主开发要交付什么**。

| 项                                                       | 必须 / 可选 | 说明                                                                      |
| ------------------------------------------------------- | ------- | ----------------------------------------------------------------------- |
| 提供 PAC 团队接入信息(联系人 / 试点 clinic 列表)                       | ✅ 必须    | Phase 1 启动                                                              |
| 颁发 **apiKey**(推荐)或 loginUrl / username / password 给 PAC | ✅ 必须    | PAC 调你的资源 API 时鉴权                                                       |
| **批量数据通道二选一**(详见 §3):                                   | ✅ 必须    | 至少一种;两种都接也可                                                             |
| ① 提供 14 种资源 endpoint(走 Pull,详见 §3.2,含 3 个产品收集类)         | —       | patients / appointments / clinical_encounters / orders / payments(必填 5) |
| ② 周期性导出脱敏裸表给 PAC(走 Cold-import,详见 §3.7)                 | —       | host 端零工程,`SELECT *` 即可                                                 |
| 提供 `/users:resolve` 端点(PAC 选客服分配时用)                     | ✅ 必须    | 见 §6 详细契约                                                               |
| 提供字段命名清单(host 自己怎么命名的)                                  | ✅ 必须    | PAC 配 fieldMapping 翻译用,**不要求 host 改字段命名**                               |
| 实现 push 接收端点 + HMAC 签名(可选)                              | ⚠️ 推荐   | 实时性更好;不接也行(纯 pull)                                                      |
| host UI 嵌入 PAC iframe **或** 直接调 PAC 开放 API              | ✅ 必须二选一 | 客服界面的形态由 host 决定                                                        |
| 提供 actionUrls 模板(deep-link 跳回 host UI)                  | ⚠️ 推荐   | 客服在 PAC iframe 里点"看患者档案"跳回 host                                         |
| 接入后的运维(数据完整性 / 故障告警 / 凭证轮换)                             | ✅ 必须    | 见 §10 七层防线对宿主的要求                                                        |

**宿主侧不需要做**(PAC 内部处理):
- 维护 PAC 召回算法逻辑
- 维护 PAC 数据库 schema
- 学 PAC canonical 字段命名(走 fieldMapping)
- 处理 PAC 侧鉴权机制(只需 appId/appSecret 换 JWT)

## 1.6 响应信封统一原则(PAC 侧 API)

> 调 PAC API 时(host backend 调 / host UI 调 / PAC 前端调),响应一律遵循:

```jsonc
// 成功
{ "code": 0, "msg": "ok", "data": { /* payload */ } }

// 失败
{
  "code":    10101,
  "msg":     "appId 或 appSecret 不正确",
  "data":    null,
  "details": [ /* 可选,结构化补充信息(如 zod 校验失败明细) */ ]
}
```

- **HTTP 状态码统一 `200`**(`/health` 和 SSE 流除外;HTTP 5xx 保留给"真正的服务崩溃"如 DB 不可达 / uncaught panic,让 LB / k8s 探针能识别死实例)
- 业务异常**全部**通过 `code` 数字区分

### Code 编号规则:**5 位 `A-BB-CC`**

| 段 | 取值 | 含义 |
|---|---|---|
| `A` | `0` 成功 / `1` 客户端错 / `2` 业务规则错 / `3` 第三方上游错 / `9` PAC 自身错 | 错误来源 / 大类 |
| `BB` | `00` 通用 / `01` auth / `02` host / `03` patient / `04` persona / `05` plan / `06` execution / `07` facts / `08` sync / `09` agent / `10` tenant | 模块 |
| `CC` | `01` `02` `03` ... | 模块内顺序 |

### 已定义的核心 code(供 host 处理参考)

```
0       OK                                  成功
10001   CLIENT_BAD_REQUEST                  请求格式错
10002   CLIENT_VALIDATION_FAILED            字段校验失败(看 details)
10003   CLIENT_RATE_LIMITED                 限流
10101   AUTH_INVALID_CREDENTIALS            appId/Secret 错
10102   AUTH_INVALID_CODE                   一次性 code 失效
10103   AUTH_INVALID_REFRESH_TOKEN          refresh token 失效
10104   AUTH_TOKEN_MISSING                  未带 access token
10105   AUTH_TOKEN_EXPIRED                  access token 过期
10106   AUTH_TOKEN_INVALID                  access token 无效
10107   AUTH_PERMISSION_DENIED              权限不足
10201   PLATFORM_NOT_FOUND                  平台不存在
10301   PATIENT_NOT_FOUND                   患者不存在
10401   PERSONA_NOT_FOUND                   画像不存在
10501   PLAN_NOT_FOUND                      计划不存在
20501   PLAN_ALREADY_ASSIGNED               计划已分配
20502   PLAN_ALREADY_COMPLETED              计划已完成,不能修改
20503   PLAN_BREAKER_TRIPPED                联系次数熔断
30801   SYNC_HOST_UNREACHABLE               宿主 API 不可达
30802   SYNC_CONTRACT_DRIFT                 字段映射漂移
30803   SYNC_HOST_AUTH_FAILED               宿主登录失败 / 凭据失效
30804   SYNC_PATIENT_PULL_NOT_SUPPORTED     adapter 不支持单患者 pull
30901   AGENT_GATEWAY_TIMEOUT               AI 网关超时
30902   AGENT_GATEWAY_ERROR                 AI 网关错
90000   INTERNAL_ERROR                      服务内部错
90001   INTERNAL_DB_ERROR                   数据库错
```

完整列表见 [`packages/types/src/schemas/wrap.ts`](../packages/types/src/schemas/wrap.ts) 的 `ApiCode` 常量。

⚠️ **统一约定** — host 侧 API(被 PAC 调用的资源 endpoint / `/users:resolve` / push 接收)
**也使用同一套 envelope**:HTTP `200` + `{ code, msg, data, details? }`,业务正常 `code=0`,
业务异常用 host 自定义 code(建议复用 PAC code 表方便联调,见各章具体码值)。

**为什么统一**:跨系统调用看到的是一种响应形态,host / PAC 两侧客户端代码可共享 envelope 解析器;
联调日志格式一致;故障分析时无需切换两种心智模型。

**唯一例外**:HTTP `5xx`(host 侧服务真崩溃)+ `/health` 探针响应 — 跟 PAC 侧一致,留给基础设施层。

---

# 2. 鉴权

## 2.1 三种凭证

| 凭证 | 谁持有 | 用途 |
|---|---|---|
| `appId` + `appSecret` | host backend | 调 PAC `/auth/token` 换 JWT |
| `pushSecretHashes[]`(可选) | host backend(plaintext)+ PAC(hash) | host 推 event 时 HMAC 签名 |
| `pullConfig.credentials` | host 自定义 | PAC 调 host 资源 API 用(host 颁发) |

**安全规则**:
- `appSecret` / `pushSecret`(plaintext)**一次性返回**,host 必须立即存好,PAC 永不回读
- 失落 → PAC 团队跑 CLI `pnpm pac:host rotate-secret <name>` / `rotate-push <name>` 重发(push 走数组 grace 期共存)
- 所有 secret 在 PAC 侧 scrypt 哈希落库,不存 plaintext

## 2.2 换票流程(host backend → PAC)

```
host 侧用户登录(host 自己的登录态)
   ↓ host backend 知道 userId / role / clinicIds / tenant
host backend → POST /pac/v1/auth/token
   body: {
     "appId":     "pac_friday_a1b2c3d4",
     "appSecret": "<host 持有的 plaintext>",
     "user": {
       "userId":    "host-side-user-id",
       "tenantId":  "host-side-tenant-id",
       "role":      "staff" | "leader" | "admin",
       "clinicIds": ["clinic_hz_dasha", "clinic_hz_gaode"]
     }
   }
   ↓
PAC verifies appSecret + signs JWT
   response: {
     "accessToken":     "<JWT bearer, 2h TTL>",
     "refreshToken":    "<refresh JWT>",
     "code":            "<one-time code, 60s TTL>",
     "expiresIn":       7200,
     "codeExpiresIn":   60
   }
   ↓
host backend 把 code 拼到 PAC iframe URL:
   https://pac.example.com/embed?code=XXX
   ↓
PAC frontend → POST /pac/v1/auth/exchange-code  { code }
   → 拿到 accessToken,后续 API 调用 Authorization: Bearer <accessToken>
```

### 关键约定

| 项 | 规则 |
|---|---|
| `role` 取值 | **必须**是 `staff` / `leader` / `admin` 之一(PAC 框架固定 3 个) |
| `clinicIds` | 用户授权可访问的所有 clinic(staff 通常 1 个,leader 多个) |
| `tenantId` | host 自定义命名空间,**接入后不能变更**(数据隔离的基础) |
| 错误码 | `10101 AUTH_INVALID_CREDENTIALS`(appId/Secret 错)/ `10102 AUTH_INVALID_CODE`(code 过期)/ `10103 AUTH_INVALID_REFRESH_TOKEN` |

## 2.3 Token 字典(host 在换票时附带 name)

PAC 不存 host 的 user 主数据,**展示需要 name 时**靠 host 在换票时附带:

```json
TokenExchangeRequest.user = {
  "userId":    "u_001",
  "tenantId":  "t_xxx",
  "role":      "staff",
  "clinicIds": ["c_1", "c_2"],
  "dictionary": {                              // 可选,推荐附带
    "userName": "张三",
    "clinics":  [
      { "id": "c_1", "name": "杭州大厦门诊" },
      { "id": "c_2", "name": "杭州高德门诊" }
    ]
  }
}
```

**字典生命周期**:跟 access token 同寿命(2h);PAC 前端 cache 用。
**字典覆盖不到的 userId**(如历史 plan 的老 assignee / 跨团队展示):PAC 后端调 host `/users:resolve` 兜底(见 §6)。

## 2.4 错误码速查

跟 §1.6 全局原则一致(HTTP 200 + envelope `code`)。

| code    | 含义                                    |
| ------- | ------------------------------------- |
| `0`     | 成功                                    |
| `10101` | appId / appSecret 错(host 不存在或停用) |
| `10102` | exchange code 失效                      |
| `10103` | refresh token 失效                      |
| `10104` | 缺少 access token                       |
| `10105` | access token 过期                       |
| `10106` | access token 无效 / 解析失败                |
| `10107` | 权限不足(role 没有该 permission / clinic 越界) |

---

# 3. 数据接入 — Pull / Cold-import(批量数据通道)

> 本章覆盖**两种批量数据接入方式**:
>   - **Pull**(§3.1-§3.6):PAC 主动调 host HTTP endpoint 拉资源(实时性中等,host 需实现 endpoint)
>   - **Cold-import**(§3.7):host 导出文件给 PAC 装载(零 host 工程量,首发最快)
>
> **两者数据形态完全一样**(host raw 表 → AssemblerEngine → canonical),
> 共用同一套 assembler.yaml 配置 — host 接通 pull 时**零额外工作**,先 cold 后 pull 平滑切换。
>
> 实时事件流(push)单独章见 §4。

## 3.1 工作机制

PAC 周期性 cron(默认每 5 分钟)+ 单患者按需,调 host 资源 endpoint 拉**当前态对象列表**(原样 host 表行 / 视图行,**不要求 host 整形成 canonical 形态**);
PAC 内部 AssemblerEngine 按 yaml 配置投影成 canonical resource → TransactionSynthesizer 合成 transaction → Parser 衍生 fact。

**关键约定**:host 端不感知 PAC canonical 概念,只需要把自己 DB 表 / 视图行通过 HTTP envelope 返回;
字段命名 / 1:N 嵌套 / 枚举翻译全由 PAC 团队在 `data/<host>/assemblers/*.yaml` 写配置消化。

### 三个 SLA

| 类型 | 频率 | 用途 |
|---|---|---|
| **周期 pull** | 默认 5 分钟 / 资源(`pullConfig.intervalMinutes`) | 批量增量同步 |
| **单患者即时 pull** | 客服打开患者页时按需调 | 实时刷新单条 |
| **对账 reconcile** | 每日 1 次(凌晨低峰) | 数据完整性兜底 |

### Host 需要提供给 PAC 团队的接入信息

PAC 控制台录入,host 提供以下内容即可(无需自己进控制台填):

```jsonc
{
  "endpoints": {
    // 患者主档(必)
    "patients":   "https://host.example.com/api/patients",
    // 业务资源 endpoint(14 种含产品收集 3 个,按 host 能提供哪些填,见 §3.2 必/可选清单)
    "consultations":       "...",
    "appointments":        "...",
    "visit_registrations": "...",
    "visit_receptions":    "...",
    "clinical_encounters": "...",
    "emr_documents":       "...",
    "charge_orders":       "...",
    "payments":            "...",
    "refunds":             "...",
    "image_studies":       "...",
    // 用户查询 endpoint(必,Plan 自动分配客服时用,见 §6)
    "users":               "https://host.example.com/api/users:resolve"
  },
  "credentials": {                       // 二选一,⭐ 推荐 apiKey 方式
    "apiKey": "<bearer-token>"           // ✅ 推荐 — 简单 / 无状态 / 易轮换
    // 或(老系统兜底)
    "loginUrl": "https://host.example.com/api/auth/login",
    "username": "pac-bot",
    "password": "<plaintext;PAC 落库 scrypt hash>"
  },
  "amountUnit":      "fen" | "yuan", // 必填,host 金额数值的单位
  "timezone":        "Asia/Shanghai", // 必填,IANA 时区(host 时间字段所在时区)
  "intervalMinutes": 5                // 可选,PAC 拉取间隔,默认 5 分钟
}
```

**鉴权方式选择**:

| 方式 | 推荐度 | 适合 |
|---|---|---|
| **`apiKey`**(Bearer) | ⭐⭐⭐ 推荐 | 现代化系统(JWT / API Gateway 已有) |
| `loginUrl + username + password` | ⭐ 兜底 | 老系统(只有 session-based 登录) |

apiKey 方式 PAC 直接 `Authorization: Bearer <apiKey>` 调,无会话维护;
login 方式 PAC 需要先登录拿 session,会话过期还要重登,**复杂且容易失败**。

## 3.2 Endpoint 列表 + 用途

Endpoint key 跟 **PAC 内部 canonical resource** 对齐;一个 endpoint 可以是 host 单张表或多张表合视图都行
(只要 envelope 里 `items` 是行数组,具体行内字段命名 / 嵌套形态由 host 自定义,PAC assembler.yaml 投影)。

| Endpoint key | PAC canonical 资源 | 推荐频率 | 必填? |
|---|---|---|---|
| `patients` | 患者主档 | 增量 | **必** |
| `consultations` | 400 / 接待中心咨询记录 | 增量 | 推荐 |
| `appointments` | 预约 | 增量 | **必** |
| `visit_registrations` | 挂号 | 增量 | 推荐 |
| `visit_receptions` | 接诊登记 | 增量 | 可选 |
| `clinical_encounters` | 医生接诊(可拆"接诊主表 + 诊断明细 + 治疗明细"三个 endpoint 也可单 endpoint 嵌套)| 增量 | **必** |
| `emr_documents` | 电子病历 | 增量 | 推荐 |
| `charge_orders` | 收费单(可拆"单主表 + 明细表")| 增量 | **必** |
| `payments` | 收款 | 增量 | **必** |
| `refunds` | 退费 | 增量 | 推荐 |
| `image_studies` | 影像资料 | 增量(metadata 即可,无需文件) | 可选 |
| `users` | 用户名解析 | 点查 | 推荐 |
| ── ⭐ 产品收集(回访中心信息字段.docx)── |  |  |  |
| `recharges` | 充值/储值/福利卡办理(中国民营牙科核心商业模式) | 增量 | 推荐(精确 LTV 必备)|
| `complaints` | 投诉记录(合规营销避雷) | 增量 | 推荐(合规硬要求)|
| `referrals` | 转介绍记录(老带新) | 增量 | 推荐(渠道归因)|

**必填 5**(`patients` / `appointments` / `clinical_encounters` / `charge_orders` / `payments`):召回算法核心依赖,缺则 PAC 价值大打折扣。
**推荐**:增强画像精度;⭐ 产品收集 3 个 + 原推荐 4 个(`consultations` / `visit_registrations` / `emr_documents` / `refunds`)。
**可选**:特定召回场景才需要。

## 3.3 通用响应契约

每个 endpoint 响应遵循统一 envelope(跟 §1.6 一致 — 跨系统统一):

```jsonc
// 成功
{
  "code": 0,
  "msg":  "ok",
  "data": {
    "items":      [ ...资源对象数组... ],
    "nextCursor": "string | null"    // 增量游标,null=没有更多;有则 PAC 用它继续拉
  }
}

// 失败(例:鉴权过期)
{
  "code":    10101,        // 复用 PAC code 表方便联调,host 可自定义
  "msg":     "credentials invalid",
  "data":    null,
  "details": null
}
```

**关于 `data.items` 数组**:
- **每页推荐 100-200 条**(平衡 HTTP 开销 + 内存压力)
- 上限建议 ≤ 500(超过 PAC 拒收 / 报错)
- 下限 ≥ 0;空 items + `nextCursor=null` 表示没有更多

### 字段命名:**host 用自己的命名直出,不用按 PAC canonical 改**

PAC 通过 AssemblerEngine + **per-host `assembler.yaml` 配置**(见 §7)做投影 / 翻译。
host 端字段命名 / 嵌套层级 / 枚举编码完全自由 — PAC 团队拿到 host 的数据字典后写 yaml 消化。

举例:host 主档里电话叫 `mobile`,生日叫 `dob`,治疗类型用 `A01`/`B01` 编码 — 完全 OK:
```yaml
field_mapping:
  phone:     mobile
  birthDate: dob
enum_mapping:
  category:
    A01: endodontic
    B01: orthodontic
```

### 数据完整性原则

PAC 召回算法的输入是 host 数据中的**业务信号**(治疗类别 / 状态 / 时间 / 下一步医嘱 / 金额 / ...)。
host 数据**有就有,没有也给不出来** — PAC 不要求 host 为对接补录字段。

- host 数据**有**的信号 → assembler.yaml 写好投影 → 召回算法精度满血
- host 数据**没有**的信号 → 对应 Persona feature / scenario 降级或不可用
- 是否补录数据 / 提升数据质量 = host 业务决策,不是 PAC 对接前置条件

详细的"哪些信号影响哪些 Persona feature / scenario"清单见 `docs/host-data-request-checklist.md` §1。

## 3.4 增量游标契约

PAC 对每个资源维护一个**水位**(`sync_logs.cursorBefore` / `cursorAfter`),host 必须支持:

```
GET <endpoint>?after=<cursor>&limit=200
   ↓
Host 返回 updatedAt > cursor 的资源(按 updatedAt 升序),最多 limit 条 + nextCursor
   ↓
PAC 消化后用 nextCursor 继续拉,直到 nextCursor=null
```

**Cursor 形态规范**:
- 可以是时间戳(ISO 8601,如 `2026-05-22T16:00:00.000Z`)
- 或 host 自定义 token(opaque string,PAC 不解析)
- 必须**单调递增**,确保不会跳过资源
- **首次 pull**(initial pull):`cursor=null`(从最早开始)

**首次 pull 的特殊处理**(避免跟 §3.7 cold-import 混淆 — "首次 pull" ≠ "cold-import"):
- Host **可以**支持 `?since=<ISO-8601>` 限定时间范围(避免拉全部历史)
- 推荐**首次 pull 只拉最近 24 个月**的数据,更早数据按需 backfill
- 历史全量 backfill 量大时,推荐改走 §3.7 cold-import(host 直接 dump 给 PAC,比 pull 高效)

## 3.5 单患者即时 pull

```
PAC → GET <endpoint>?patientExternalId=P00012345
```

- **不推进 cursor**(避免污染批量水位)
- 客服打开患者页 30 秒内的多次调用 → PAC 内部 dedupe(只发一次 host 请求)
- 60 秒冷却期(避免短时间反复刷)

Host 返回**该患者所有相关资源的当前态**(不限增量,做"全量当前态拉取")。

## 3.6 错误处理(host 的响应行为)

> Host 侧响应一律走 envelope(跟 §1.6 一致),HTTP 状态码统一 `200`,业务异常通过 `code` 区分。

| 场景 | Host 应该返回 | PAC 行为 |
|---|---|---|
| 正常数据 | `code=0` + `data.items` + `data.nextCursor` | 消化数据,推进 cursor |
| 当前已最新无更多 | `code=0` + `data.items=[]` + `data.nextCursor=null` | 标记为成功,跳过下次直到下个 cron |
| 鉴权失败 | `code=10101` `AUTH_INVALID_CREDENTIALS` | PAC 触发凭证轮换告警,该资源暂停 pull |
| 单患者 pull 找不到该患者 | `code=10301` `PATIENT_NOT_FOUND` | PAC 标该患者 archived,记录 sync_log |
| 限流 | `code=10003` `CLIENT_RATE_LIMITED` | PAC 指数退避 + jitter 重试 |
| Host 内部异常 | `code` ∈ `90xxx` 或 HTTP `5xx`(真崩溃) | PAC 重试 3 次,失败转 partial,下次继续 |

**说明**:
- Host 可以**复用 PAC code 表**的码值(方便联调日志对齐),也可以自定义 host 自己的 code 体系 — 唯一硬约束是 `code=0` 表示成功
- HTTP `5xx` **保留给 host 服务真崩溃**(网关挂 / 服务挂),不用于业务异常

## 3.7 冷数据导入(Cold-import)

> Host 导出脱敏裸表给 PAC,PAC 用 cold-import CLI 装载。**不需要 host 实现任何 HTTP endpoint**。

### 3.7.1 业务定位

| 维度 | Cold-import | Pull(对比)|
|---|---|---|
| **传输方式** | host 导出 CSV/JSON 文件 → PAC | PAC 调 host HTTP endpoint |
| **host 工程量** | **极低**(`SELECT * FROM tb_xxx` 导出)| 中(实现 §3.2 列出的 endpoint)|
| **实时性** | 离线 / 周期性导出(日 / 周 / 月)| 准实时(默认 5 分钟)|
| **PAC 端实现** | 跟 pull **共用** AssemblerEngine + assembler.yaml + 下游 pipeline | 同 |
| **数据形态** | 跟 pull **100% 一致**(host raw 表 → canonical) | 同 |
| **适用场景** | 试点接入 / 联调验证 / 长期非实时模式 / pull 暂时不便实施 | 接入成熟后的常态 |

**核心**:cold-import 是"数据通道形态"的差异(文件 vs HTTP),**不是数据形态差异** —
host 接通 pull 时,assembler.yaml 配置零修改,**两种通道无缝切换**。

### 3.7.2 Host 交付物

```
friday-export-2026-05-15/
├── README.md             ← 数据字典(每张表 + 每个字段 + 枚举值含义)
├── manifest.yaml         ← 配置(host_name + tables 清单 + assemblers 引用,见 §3.7.3)
│
├── tb_patient.csv        ← SELECT * 原样(host 自己表名,assembler.yaml 映射)
├── tb_encounter.csv
├── tb_dx.csv             ← tb_dx.encounter_id = tb_encounter.encounter_no(1:N)
├── tb_tx.csv             ← 同上
├── tb_appointment.csv
├── tb_order.csv
├── tb_order_item.csv     ← 跟 tb_order 1:N
└── tb_payment.csv
```

文件细节同 §3.2 资源清单(必填 5 + 可选 6 + 产品收集 3)。**字段命名 / 嵌套形态 / 枚举编码 host 完全自由** — assembler.yaml 在 PAC 侧映射(见 §7)。

### 3.7.3 manifest.yaml 形态

```yaml
host_name: friday              # 必须跟 PAC 已注册的 host.name 一致(SSH CLI 创建时的 slug)
tenant_id: t_friday_grp1
amount_unit: yuan              # fen / yuan
timezone: Asia/Shanghai

tables:
  - { table: tb_patient,      file: tb_patient.csv }
  - { table: tb_encounter,    file: tb_encounter.csv }
  - { table: tb_dx,           file: tb_dx.csv }
  - { table: tb_tx,           file: tb_tx.csv }
  - { table: tb_appointment,  file: tb_appointment.csv }
  - { table: tb_order,        file: tb_order.csv }
  - { table: tb_order_item,   file: tb_order_item.csv }
  - { table: tb_payment,      file: tb_payment.csv }

assemblers:
  - { file: assemblers/patient.yaml }
  - { file: assemblers/clinical_encounter.yaml }
  - { file: assemblers/appointment.yaml }
  - { file: assemblers/order.yaml }
  - { file: assemblers/payment.yaml }
```

`assemblers/*.yaml` 由 **PAC 团队基于 host 的数据字典编写**(见 §7 详细形态)。

### 3.7.4 PAC 端跑命令

```bash
cd apps/pac-service

# 先 dry-run 看翻译效果
pnpm cold-import -- --dir=./data/<host>/<bundle> --dry-run

# 真写库
pnpm cold-import -- --dir=./data/<host>/<bundle>

# 验证 timeline 出得来
pnpm timeline -- --host=<host_name> --tenant=<tenant_id> --pid=<host_pid>
```

成功标准 — 控制台输出:
```
status:                success
patients upserted:     <N>
transactions written:  <N>
duplicates (idem):     0
failed:                0
facts:                 created=<M> ...
```

如有 `[drift] xxx row rejected: <字段路径>` → assembler.yaml 跟 host 实际数据形态错位,PAC 团队改 yaml 重跑,**不需要 host 改任何东西**。

### 3.7.5 何时退出 cold-import,切到 pull / push?

灵活,**没有"必须切"的硬要求**:

| host 现状 | 推荐通道 |
|---|---|
| 没准备好 endpoint(开发周期 / 联调成本)| 长期 cold-import 也可,日 / 周 / 月导出循环 |
| 实时性要求高(召回希望 < 1h 看到事件)| 切 pull(默认 5 分钟)|
| 已实现完整 webhook + 愿意主动推 | 加 push(实时事件流)|
| Pull + Cold 混合 | 完全可以 — 历史数据冷启,后续增量 pull |

**纪律**:同一 host 同一资源**不要同时跑 pull + cold**(避免重复入库)— 设计上幂等(source_event_id)能扛,但日志会乱。

---

# 4. Push 路径(宿主主动推事件)

## 4.1 选择:Pull 还是 Push?

| 维度 | Pull | Push |
|---|---|---|
| 实时性 | ⚠️ 5 分钟延迟 | ✅ 秒级 |
| Host 改造代价 | ⚠️ host 要支持增量 cursor + endpoints | ✅ host 只要发事件 |
| 实施复杂度 | ✅ PAC 主动拉,host 被动 | ⚠️ host 主动推 + HMAC + 重试 |
| 适合场景 | host 没事件总线 / 历史系统 | host 有现代事件架构 |

**推荐**:**Pull 兜底,Push 增强**。Pull 必接,Push 可选;两者同时启用时 PAC 自动 dedupe(靠 `source_event_id` 幂等)。

## 4.2 Push endpoint

```
POST https://pac.example.com/pac/v1/push/events

Headers:
  Content-Type: application/json
  X-Pac-Host-Id: <host UUID,PAC 注册时给>
  X-Pac-Signature:  <HMAC-SHA256 of body>
  X-Pac-Timestamp:  <Unix ms>

Body:
  see §4.3
```

## 4.3 报文形状

```jsonc
{
  "events": [
    {
      "sourceEventId":   "host-evt-88123",     // ⭐ 必填,host 原生事件 ID,幂等键
      "sourceActorId":   "doc_zhang",          // 可选,操作人(host user id)
      "sourceActorRole": "doctor",             // 可选
      "clinicId":        "clinic_hz_dasha",    // ⭐ 必填,操作发生诊所
      "patientExternalId": "P00012345",        // 关联患者(无 patient 主体的管理事件可省)
      "action":          "appointment_created", // ⭐ 必填,见 §4.4 18 个枚举
      "subjectType":     "appointment",        // ⭐ 必填,见 §4.4 13 个枚举
      "subjectId":       "apt_001",            // ⭐ 必填,host 侧业务对象 id
      "rawPayload":      { ...host 原文... },   // ⭐ 必填,host 原生 payload(用于 audit / 重放)
      "occurredAt":      "2026-05-10T14:30:00+08:00" // ⭐ 必填,ISO 8601 带时区
    }
    // ... 单次最多 500 个事件
  ]
}
```

## 4.4 18 个 `action`(14 起步 + 4 产品收集)× 13 个 `subjectType`(10 起步 + 3 产品收集)

应用层 zod 校验,schema 层 TEXT 不强枚举,允许扩展。形式 `英文_key`(中文)。

| `action` | 对应 `subjectType` | 典型业务时机 |
|---|---|---|
| `consultation_created` | `consultation` | 400 中心 / 前台接待录入咨询 |
| `appointment_created` | `appointment` | 预约创建 |
| `appointment_changed` | `appointment` | 预约改时间 / 改医生 |
| `appointment_cancelled` | `appointment` | 预约取消 |
| `patient_arrived` | `visit_reception` | 患者到店签到 |
| `visit_registered` | `visit_registration` | 挂号成功 |
| `patient_received` | `visit_reception` | 前台接诊 |
| `doctor_encounter_started` | `clinical_encounter` | 医生开始接诊 |
| `charge_order_submitted` | `charge_order` | 收费单提交 |
| `payment_received` | `payment` | 收款确认 |
| `refund_created` | `refund` | 退费 |
| `emr_submitted` | `emr_document` | 病历提交 |
| `emr_rejected` | `emr_document` | 病历打回 / 质控不通过 |
| `image_uploaded` | `image_study` | 影像上传 |
| ── ⭐ 产品收集(回访中心信息字段.docx)── |  |  |
| `recharge_received` | `recharge` | 患者充值 / 办储值卡 / 福利卡 |
| `complaint_filed` | `complaint` | 客户投诉提交 |
| `complaint_resolved` | `complaint` | 投诉处理结案 |
| `referral_recorded` | `referral` | 老客转介绍登记 |

**注**:
- `action` 越细越好(贴近 host 真实业务动作)
- 同一 `subjectType` 可能对应多个 `action`(如 appointment created / changed / cancelled,complaint filed / resolved)
- 起步 enum 一旦不够用,在 @pac/types/enums + 本表同步扩展即可

## 4.5 HMAC 签名规范

PAC 用 `pushSecretHashes` 数组验签 — 支持平滑密钥轮换。

### Host 签名步骤

```
1. signing_string = X-Pac-Timestamp + "\n" + sha256(body)
2. signature      = base64( HMAC_SHA256(pushSecret_plaintext, signing_string) )
3. Header X-Pac-Signature = signature
```

### PAC 验签步骤

```
1. 检查 X-Pac-Timestamp 跟当前时间差 < 5 分钟(防 replay)
2. 重算 expected_signature
3. 逐个对比 host.pushSecretHashes 数组中所有 key,任一命中即通过
4. 否则返回 `code=10108`(HMAC 签名验证失败)
```

### 密钥轮换流程

```
当前:host.pushSecretHashes = [hash_v1]
   ↓ host 想轮换
PAC 团队 SSH → `pnpm pac:host rotate-push <name>`
            stdout 显示新 plaintext pushSecret_v2 + total_keys=2
PAC 落库:host.pushSecretHashes = [hash_v2, hash_v1]   // v2 在前 = current
   ↓ host 收到 v2,逐步把生产流量切到 v2 签名
   ↓ 完全切完后(grace period 24-48h)
PAC 团队跑 DB 清理脚本(本期不暴露专用 CLI)
PAC 落库:host.pushSecretHashes = [hash_v2]            // v1 彻底失效
```

**Host 侧最佳实践**:同时持有 v1 / v2 两个 secret,逐步切流(灰度),零停机切换。

## 4.6 错误响应

跟 §1.6 全局原则一致(HTTP 200 + envelope `code`):

| code    | 含义                                  | Host 行为 |
| ------- | ----------------------------------- | --- |
| `0`     | 全部受理 + 写库成功                          | OK |
| `0`(data.partial=true) | 部分受理,`data.errors[]` 含失败索引 + 原因 | 按 errors[] 重发对应 event |
| `10001` | 报文结构错(整体格式不对)                       | 检查报文 |
| `10002` | 字段校验失败(`details[]` 含 zod issues)    | 按 details 修正 |
| `10003` | PAC 侧限流                              | 指数退避重试 |
| `10101` | appId / appSecret 错(host 无效)    | 检查凭证 |
| `10108`* | HMAC 签名验证失败                         | 检查 pushSecret / 签名算法 |
| `10109`* | 时间戳过期(>5 min 或时钟漂移)                 | 同步本地时钟 |
| `90000`/`90001` | PAC 内部错                          | 指数退避重试,持续失败联系 PAC 团队 |

\* `10108` / `10109` 是 push 路径专用 code,加入 `ApiCode` 待 PAC 内部 push receiver 实现时落地。

**幂等保证**:同一 `source_event_id` 重复提交,PAC **直接返回 `code=0`**(把重复当成功处理,响应里 `data.duplicates` 统计去重数量);host 不需要特殊处理。

## 4.7 单次最大 batch

- 单次 POST 最多 **500 events**(性能 / 内存平衡)
- 推荐 batch 大小:**50-100**(平衡延迟 / 吞吐)
- 超过 500 → `code=10001 CLIENT_BAD_REQUEST` + `details: { reason: "batch_too_large" }`

---

# 5. PAC 开放 REST API

> 这章讲**宿主可以调 PAC 的哪些接口**(host 前端 / 后端 / PAC 自有前端 — 都可以)。

## 5.1 什么时候用这些接口

| 场景 | 调用方 | 调哪些 |
|---|---|---|
| 客服界面展示召回任务清单 | host 前端 | `GET /pac/v1/plans` |
| 客服打开患者卡片看详情 | host 前端 | `GET /pac/v1/plans/{id}` + `GET /pac/v1/patients/{id}/timeline` + `GET /pac/v1/patients/{id}/persona` |
| 客服打完电话保存执行结果 | host 前端 | `POST /pac/v1/plans/{id}/executions` |
| 主管分配 plan 给某客服 | host 前端 | `POST /pac/v1/plans/{id}/assign` |
| 主管回收无人跟进的 plan | host 前端 | `POST /pac/v1/plans/{id}/recycle` |
| 客服点"刷新患者最新数据" | host 前端 | `POST /pac/v1/sync/patient/{id}/refresh` |
| host dashboard 同步召回数据 | host 后端 | `GET /pac/v1/plans?status=...&updatedAfter=...` 轮询 |
| 运维触发某 plan 重生 scripts/summaries | host 后端 / PAC 后台 | `POST /pac/v1/plans/{id}/assets:regenerate` |

## 5.2 完整 API 参考(看 Swagger)

PAC 服务用 NestJS + Swagger 自动生成 API 参考。**接入方拿到最完整字段定义和 try-out 工具的渠道**:

| 渠道 | URL | 说明 |
|---|---|---|
| **Swagger UI**(交互式) | `https://<pac-host>/api/docs` | 可视化 + try-out |
| **OpenAPI JSON** | `https://<pac-host>/api/openapi.json` | 给 client codegen / Postman / API mock 工具用 |
| **离线 Redoc HTML** | 见附件 `docs/api/pac-redoc-v0.html` | 接入方拿到对接文档时一并附带,离线可查 |

⚠️ v0 阶段 PAC 服务尚未公网部署;接入方:
- 本地启动 PAC 服务(参见项目 README)→ 访问 `http://localhost:3101/api/docs`
- 或看附件 Redoc HTML(对接文档发出时一同发)

## 5.3 关键业务流(序列图)

### 5.3.1 客服打电话流程

```mermaid
sequenceDiagram
  participant CSR as 客服 (host UI)
  participant PAC
  
  CSR->>PAC: GET /pac/v1/plans?view=mine&status=active
  PAC-->>CSR: 我负责的 active plan 列表
  
  CSR->>PAC: GET /pac/v1/plans/{id}
  PAC-->>CSR: Plan 详情 + patient + persona + script + summaries
  
  Note over CSR: 客服打电话 / 看话术 / 填备注
  
  CSR->>PAC: POST /pac/v1/plans/{id}/executions<br/>{ outcome, notes, channel, ... }
  PAC->>PAC: 写 plan_executions + 更新 plan.status<br/>(success_appointed → completed / abandoned → ...)
  PAC-->>CSR: { code: 0, data: { executionId } }
```

### 5.3.2 单患者即时刷新数据流程

```mermaid
sequenceDiagram
  participant CSR as 客服 (host UI)
  participant PAC
  participant Host as Host 资源 API
  
  CSR->>PAC: POST /pac/v1/sync/patient/{id}/refresh
  PAC->>Host: GET /api/patients/{externalId} 等(单患者 pull)
  Host-->>PAC: 当前态资源
  PAC->>PAC: 比对合成 transactions + 衍生 facts
  PAC-->>CSR: { code: 0, data: { syncLogId, status: "success" } }
  
  Note over CSR: 前端刷新 plan 卡片<br/>(可能触发 plan 重算 → 新版本)
```

## 5.4 速查表(粗粒度;字段细节看 Swagger)

| 模块 | 方法 | 路径 | 用途 |
|---|---|---|---|
| auth | POST | `/auth/token` | 换 JWT(host 后端调,见 §2) |
| auth | POST | `/auth/exchange-code` | 用一次性 code 换 JWT(host 前端调,见 §2) |
| plans | GET | `/plans` | 列召回池;支持 `view=pool/mine/all` / `status` / `targetClinicId` 等过滤 |
| plans | GET | `/plans/{id}` | Plan 详情 + 关联 patient/persona/scripts/summaries/executions |
| plans | POST | `/plans/{id}/assign` | 分配给客服 |
| plans | POST | `/plans/{id}/recycle` | 回收(回到 active 池) |
| plans | POST | `/plans/{id}/executions` | ⭐ 提交执行结果 |
| plans | POST | `/plans/{id}/assets:regenerate` | 重生 scripts/summaries(运维 / 客服不满意场景) |
| plans | POST | `/plans/{id}/recompute` | 强制 Plan 重算(运维) |
| patients | GET | `/patients/{id}/timeline` | 患者时间轴(active facts + 主档) |
| persona | GET | `/patients/{id}/persona` | 患者当前画像(features 含描述 / 证据) |
| sync | POST | `/sync/patient/{id}/refresh` | 单患者即时 pull |
| sync | POST | `/sync/trigger` | 触发全量批量 pull(运维) |
| admin | GET / PATCH | `/admin/hosts/*` | 只读 + 受限更新(actionUrls/pullConfig/active)。create / rotate / deactivate **走 CLI**(见 §10) |
| health | GET | `/health` | 健康检查(无 envelope) |

完整字段定义、参数、响应 schema → 见 Swagger / Redoc。

## 5.5 共用约定

- **鉴权**:除 `/auth/*` 和 `/health` 外,所有路径都需要 `Authorization: Bearer <accessToken>`
- **响应信封**:HTTP 200 + `{ code, msg, data, details? }`(§1.6)
- **分页**:列表接口都支持 `?page=1&pageSize=20`(默认值,可调整;`pageSize` ≤ 100)
- **时间字段**:全部 ISO 8601 带时区(`2026-05-12T10:00:00+08:00`),PAC 不接收无时区字符串
- **金额字段**:全部以"分"为单位的整数(`amountCents`),前端格式化展示

---

# 6. 用户查询接口(host 提供给 PAC)

> 这个接口由 **host 实现**,PAC 调它。用于 PAC 自动分配 Plan 给客服时,
> 按角色 / clinic 查可用客服列表,或按 id 解析单个 user。

## 6.1 端点位置

`pullConfig.endpoints.users` 配置的 URL,通常是 `https://<host>/api/users:resolve`。

## 6.2 何时被调用

| 场景 | PAC 怎么用 |
|---|---|
| Plan 自动分配 | 查"该 clinic + role=staff + active 在职" 的客服列表 → PAC 按工作量 / 业务规则选一个 |
| Plan 历史展示 | 客服 / 主管打开历史 Plan,显示当时 assignee 的 name(可能此人离职,字典里没)→ 按 id 解析 |
| 跨团队列表展示 | 主管的 dashboard 显示其他团队执行情况 → 批量解析 userIds |
| 运维查看 | admin 调试 Plan 时显示完整 user 信息 |

## 6.3 请求 / 响应契约

### 请求

`POST <users-endpoint>` (Bearer / API key 鉴权,跟 §3.1 一套凭证)

```jsonc
{
  // 模式 1:按 id 批量解析
  "byIds": ["u_001", "u_002"],

  // 模式 2:按条件查列表
  "list": {
    "tenantId":  "t_xxx",          // 必填
    "role":      "staff",           // 可选:staff / leader / admin / null=不限
    "clinicIds": ["c_1"],           // 可选:限定 clinic 范围;空数组或缺失 = 不限
    "active":    true               // 可选:true=只要在职,默认 true
  }
}
```

**两个模式互斥**(单次请求填 `byIds` 或 `list`,不要都填)。

### 响应

```jsonc
{
  "code": 0,
  "msg":  "ok",
  "data": {
    "users": [
      {
        "id":        "u_001",       // 🔴 必填
        "name":      "张三",         // 🔴 必填
        "role":      "staff",        // 🔴 必填(staff / leader / admin)
        "clinicIds": ["c_1"],        // 🔴 必填(该 user 授权的 clinic 列表)
        "active":    true,           // 🔴 必填(在职 = true / 离职 = false)
        "workload":  3               // 🟢 可选(当前已分配未完成 plan 数;PAC 自动分配时用)
      },
      {
        "id":      "u_002",
        "name":    "李四(离职)",
        "role":    "staff",
        "clinicIds": [],
        "active":  false             // 离职 user 也要返回(用于历史 plan 展示)
      }
    ]
  }
}
```

### 字段说明

| 字段 | 必/推/选 | 说明 |
|---|---|---|
| `id` | 🔴 必填 | host 侧 user id,跟 token 里的 userId 一致 |
| `name` | 🔴 必填 | 展示用 |
| `role` | 🔴 必填 | 限 `staff` / `leader` / `admin`,跟 token 一致 |
| `clinicIds` | 🔴 必填 | 该 user 当前授权的 clinic;离职 user 通常返回空数组 |
| `active` | 🔴 必填 | 在职状态;**离职 user 也要能解析到**(历史 plan 需要显示) |
| `workload` | 🟢 可选 | 当前未完成召回数;PAC 自动分配负载均衡用;缺失时 PAC 走 round-robin |

## 6.4 错误处理

跟 §3.6 一致(envelope 体系):

| 场景 | host 返回 |
|---|---|
| 成功 | `code=0` + `data.users` |
| 鉴权失败 | `code=10101` |
| 模式参数都没填 / 都填了 | `code=10002` `CLIENT_VALIDATION_FAILED` + `details` |
| host 内部异常 | `code` ∈ `90xxx` 或 HTTP `5xx` |

## 6.5 性能要求

- PAC 自动分配会在**每个 Plan 重算时**调用 1-3 次,响应应**< 500ms**
- 单次 `byIds` 长度 ≤ 200
- 单次 `list` 响应 `users` 长度 ≤ 500;超过用分页(`list.cursor` 留扩展位)
- PAC 侧 5 分钟 LRU 缓存命中过的结果(减少打 host)

---

# 7. AssemblerEngine — host 数据投影到 PAC canonical

> Host **用自己的表 + 字段命名直出**,所有"映射 / 翻译 / 投影"由 PAC 端的 yaml 配置消化。
> Host 只需要给 PAC 团队**一份数据字典**(描述表 + 字段 + 枚举值含义),**不写代码 / 不进控制台 / 不改 DB**。

## 7.1 工作机制(v2)

```
Host 数据通道(三选一,raw 形态)
  ┌──────────────┬─────────────────┬───────────────────┐
  │ Pull API     │ Push webhook    │ Cold-import 文件  │
  │ items[] raw  │ items[] raw     │ tb_xxx.csv raw    │
  └──────┬───────┴────────┬────────┴─────────┬─────────┘
         │                │                  │
         └────────────────┴──────────────────┘
                          ▼
            ┌─────────────────────────────────┐
            │  AssemblerEngine(yaml 驱动)    │
            │   ① field_mapping  字段名翻译  │
            │   ② enum_mapping   编码值翻译  │
            │   ③ join_arrays    多表 1:N 嵌套│
            │   ④ normalize      yuan→cents/tz/bool/gender │
            │   ⑤ zod canonical  失败 → ContractDriftError │
            └────────────────┬────────────────┘
                             ▼
              TransactionSynthesizer → ParserPipeline → patient_facts
```

**单点归一化**:AssemblerEngine 在入站时一次性投影到 canonical;
下游 Synthesizer / Parser / Persona / Plan 全部消费 canonical 形态,跟 host 命名 / 编码完全解耦。

## 7.2 Per-host yaml 目录结构

每个 host 一个目录(对应 `apps/pac-service/data/<host-slug>/`):

```
data/friday/
├── manifest.yaml                       ← 入口(声明 raw 表 + assembler 配置清单)
├── README.md                           ← host 给的数据字典(枚举值含义)
├── assemblers/
│   ├── patient.yaml                    ← 主档投影
│   ├── clinical_encounter.yaml         ← 接诊(可能 join 诊断/治疗子表)
│   ├── appointment.yaml
│   ├── charge_order.yaml               ← 收费单(可能 join items 子表)
│   ├── payment.yaml
│   └── ... (按 host 能提供的资源补)
└── (raw tables 数据 — cold-import 路径放 CSV;pull 路径走 host API 不存)
```

## 7.3 assembler.yaml 形态

```yaml
canonical: clinical_encounter            # 必须是 PAC 注册的 CanonicalResourceKey

# 主表:每行 = 一个 canonical resource
primary:
  table: tb_encounter
  key:   encounter_no                    # 主键(供子表 foreign_key 关联用)

# 主表字段映射:左 PAC canonical 字段,右 host 字段名
field_mapping:
  externalId:        encounter_no
  patientExternalId: patient_pid
  clinicId:          clinic_code
  doctorId:          doctor_code
  startedAt:         enc_start_time
  notes:             doctor_note

# 1:N 子表嵌套(可选):canonical 数组字段 ← host 子表
join_arrays:
  diagnoses:
    table: tb_dx
    foreign_key: encounter_id            # tb_dx.encounter_id = tb_encounter.encounter_no
    field_mapping:
      code:          dx_code
      name:          dx_name
      toothPosition: tooth_no

  treatments:
    table: tb_tx
    foreign_key: encounter_id
    field_mapping:
      category:                    tx_type
      subtype:                     tx_subtype
      toothPosition:               tooth_no
      status:                      tx_status
      expectedNextStep:            next_step
      expectedNextStepWindowDays:  next_step_days
    enum_mapping:                        # host 编码 → PAC 枚举
      category:
        A01: endodontic
        A02: prosthodontic
        B01: orthodontic
        C01: implant
        D01: periodontic
      status:
        "0": in_progress
        "1": completed

# 主表本身的字段也可以有 enum_mapping(此 encounter 例子主表没有)
enum_mapping: {}
```

## 7.4 类型自动转换(全部 PAC 自动处理)

AssemblerEngine 内嵌 normalize layer,host 完全不用关心以下转换:

| 转换 | host 形态 | PAC canonical 形态 | 触发条件 |
|---|---|---|---|
| 金额单位 | `fee: 3200`(元小数) | `amount: 320000`(分) | `manifest.amount_unit = yuan` |
| 同上 | `fee: 3200`(分) | `amount: 3200` | `manifest.amount_unit = fen` |
| 时区 | `"2026-05-10 14:00:00"`(无 tz) | `"2026-05-10T14:00:00+08:00"` | `manifest.timezone = Asia/Shanghai` |
| 性别枚举 | `sex: "男" / "女"` | `gender: "M" / "F"` | 自动识别中文 |
| 布尔字段 | `is_blacklisted: 0/1/"Y"/"N"/"true"` | `doNotContact: false/true` | 字段在 canonical schema 标 `boolean` |
| JSON 数组字符串 | CSV 里 `'[{...}]'` 字符串 | 真数组 | 字段在 canonical schema 标 `array` |

**剩下 5% 复杂转换**(如医生备注 NLP 抽取下一步)— yaml 暂不支持,挂 `custom_extractor` 钩子点对点写函数。当下未实装,真碰到再加。

## 7.5 host 准备清单(对方实际交付什么)

接入时 host 给 PAC 的物理交付物:

```markdown
# Host 接入交付清单

## 基本信息
- host 名(英文 slug,如 friday / 5i5ya)
- 联系人(技术对接 + 业务对接)
- 接入资源范围(对照 §3.2 表,标注 host 能提供哪些)

## 数据通道(三选一,推荐先 cold-import 走通再开 pull/push)
- Pull(推荐):提供 resource endpoint URL 清单 + 鉴权方式(apiKey ⭐ 或 loginUrl)
- Push:提供 host backend 推送 endpoint(PAC 收) + HMAC 密钥
- Cold-import(初装最快):导出脱敏裸表(SELECT * FROM tb_xxx → CSV)

## 数据字典(关键 ⭐)
- 每张表的中文含义
- 每个字段名 + 类型 + 中文含义
- **枚举值含义**(如 `tx_type: A01=根管,B01=正畸,C01=种植...`)
- 1:N 关联(如 tb_dx.encounter_id → tb_encounter.encounter_no)

## 单位 + 时区
- amountUnit:fen / yuan
- timezone:IANA 时区

## actionUrls(deep-link 模板,见 §8)
- VIEW_PATIENT:        "https://<host>/patient/{patientId}"
- CREATE_APPOINTMENT:  "https://<host>/appointment/new?patientId={patientId}"
```

⚠️ **host 不需要**:
- 不需要懂 PAC canonical 概念
- 不需要按 PAC 字段名改 DB / API
- 不需要做 JSON 数组聚合
- 不需要翻译枚举值(告诉 PAC 编码含义即可)
- 不需要进 PAC 控制台填配置

**PAC 团队拿到后**:
1. SSH 服务器跑 `pnpm pac:host create --name=<slug>` → 拿 appId / appSecret 一次性
2. 把凭证发给 host
3. 基于 host 数据字典写 5-8 份 `data/<host-slug>/assemblers/*.yaml`(半天活)
4. 跑 cold-import 验证 → 看到 `[drift] xxx row rejected` 就调 yaml 重跑
5. cold 通了再开 pull/push 通道(W5+)

---

# 8. Action URLs

> Host 提供一组**跳转 URL 模板**,PAC 前端 / iframe 在合适场景点按钮 deep-link 跳回 host UI。
> 比如客服在 PAC 召回卡片点"查看患者档案" → 跳 host EMR 详情页。

## 8.1 配置形态

存在 host.actionUrls(JSON object):

```jsonc
{
  "VIEW_PATIENT":       "https://host.example.com/patient/{patientId}",
  "VIEW_EMR":           "https://host.example.com/emr?patientId={patientId}",
  "VIEW_APPOINTMENT":   "https://host.example.com/appointment/{appointmentId}",
  "CREATE_APPOINTMENT": "https://host.example.com/appointment/new?patientId={patientId}",
  "VIEW_XRAY":          "https://host.example.com/imaging/{imageId}",
  "CALL_PATIENT":       null,                                  // null = host 不支持该动作,PAC 前端按钮不渲染
  "SEND_WECHAT":        "wxwork://im/chat?phone={patientPhone}"
}
```

## 8.2 标准动作清单(候选 — 按需提供)

| Action key | 占位符 | 用途 |
|---|---|---|
| `VIEW_PATIENT` | `{patientId}` | 跳到 host 患者档案页(EMR 入口) |
| `VIEW_EMR` | `{patientId}` 或 `{emrId}` | 跳到 host 完整病历页 |
| `VIEW_APPOINTMENT` | `{appointmentId}` | 跳到 host 预约详情 |
| `CREATE_APPOINTMENT` | `{patientId}` | 跳到 host 创建预约页(召回成功后客服直接开单) |
| `VIEW_XRAY` | `{imageId}` 或 `{patientId}` | 跳到 host 影像页 |
| `VIEW_TREATMENT_PLAN` | `{patientId}` | 跳到 host 治疗计划页 |
| `CALL_PATIENT` | `{patientPhone}` | host 集成 CTI / 外呼系统(若有)|
| `SEND_WECHAT` | `{patientPhone}` 或 `{wecomId}` | 跳企微聊天窗(若 host 集成企微) |

Host **只提供能支持的**,缺的留 null 或不写;PAC 前端按需渲染按钮。

## 8.3 占位符规范

PAC 前端 / iframe 调用时按字面替换:

| 占位符 | 替换为 | 注 |
|---|---|---|
| `{patientId}` | host 侧 patient external id | 必备 |
| `{patientPhone}` | 患者电话(经 PAC 脱敏 / 加密决定是否传) | host 配合脱敏策略 |
| `{appointmentId}` | host 侧预约 external id | |
| `{emrId}` | host 侧病历 id | |
| `{imageId}` | host 侧影像 id | |
| `{clinicId}` | clinic id | host 多诊所路由用 |
| `{userId}` | 当前登录客服 id | host 自动登录态贯通 |

**模板未提供** = 该动作 PAC 前端按钮不渲染(等同 null)。
**新增 action** = host + PAC 协商加新 key,不需要 schema 改动(JSON 自由扩展)。

## 8.4 跨域 + 单点登录

PAC iframe 跳 host UI 通常需要客服已登录态延续:

| 方案 | host 实现 |
|---|---|
| **方案 A(推荐)**:host 全局 cookie 跨域共享 | 跳过去自动登录 |
| **方案 B**:URL 带短时 token | `?token={hostSessionToken}`,host 验签后建立会话 |
| **方案 C**:host 接受未登录跳转 → 跳到登录页让客服登录 | UX 较差,客服会断流 |

接入时确认 host 用哪种方案,PAC 前端按需注入额外占位符(如 `{hostSessionToken}`)。

---

# 9. PAC Outbound Webhook(可选 / 嵌 iframe 时考虑)

> **v1 不提供**。**Host 自有前端调 PAC 开放 API 模式**下用不上(host 自己驱动状态)。
> **仅当** host 内嵌 PAC iframe 且外层 host UI 需要感知 iframe 内部状态变化时,才需要。

## 9.1 典型场景(将来可能用到)

```
客服在 host UI 的"召回任务" tab(其实是嵌套的 PAC iframe)
  ↓ 客服在 iframe 里点"保存执行结果"
  ↓ PAC 后端写库 + Plan 状态变 completed
  
此时 host 外层 UI 不知道这件事
  ↓ 怎么知道?
  
方案 A:iframe 跟 host 用 postMessage(浏览器层)— 最常见
方案 B:PAC outbound webhook → host backend → host UI 通过 SSE/WS 推回(全链路异步)
```

**方案 A(postMessage)更常见**,实现轻量,实时性高,**v1 推荐用此**。
方案 B(webhook)适合 host backend 需要数据落地的场景(例如 DW / 报表)。

## 9.2 v1 替代方案 — 轮询

Host 后端想要感知 PAC 自驱事件(daily cron 新增 plan / 自动 recycle / 熔断 abandoned),**轮询开放 API 即可**:

```
host backend cron 每 N 分钟跑一次:
  GET /pac/v1/plans?status=active&updatedAfter=<lastCursor>
  GET /pac/v1/plans?status=completed&updatedAfter=<lastCursor>
  GET /pac/v1/plans?status=abandoned&updatedAfter=<lastCursor>
```

毫秒级实时性差距对绝大多数场景(dashboard / DW 同步)无感。

## 9.3 W6+ 评估接口契约

真有秒级 push 需求时,W6+ 评估实现:

```
POST <host 配置的 webhook URL>
Headers:
  X-Pac-Signature:  <HMAC-SHA256>
  X-Pac-Timestamp:  <Unix ms>
Body:
  {
    "eventId":   "<PAC 生成,幂等键>",
    "eventType": "plan.assigned" | "plan.completed" | "plan.abandoned" | "plan.recycled",
    "occurredAt": "<ISO 8601>",
    "hostId": "<UUID>",
    "tenantId":   "...",
    "data":       { ...plan 关键字段... }
  }
```

届时 host 在 `pullConfig.endpoints.webhookReceiver` 配置接收 URL,新加 `webhookSecretHashes[]` HMAC 共享密钥(对称 §4.5 push 验签)。

---

# 10. 接入流程 + Checklist

## 10.1 阶段流程图

(同 §1.4 概览图,这里展开每阶段 host 要做的事 + 输出物)

### Phase 1:注册接入(1 周内)

| host 输入 | host 输出 |
|---|---|
| 项目负责人(技术 + 业务)+ 联系方式 | 技术联系群 / 工单流程 |
| 试点 clinic 清单(id + name) | clinic 字典(给 PAC 录入 + token 字典用) |
| 接入模式选择(纯 pull / pull+push) | 决策 |
| §10.2 配置清单 | yaml / 文档形式给 PAC 团队 |

PAC 团队拿到后:**SSH 服务器跑 `pnpm pac:host create --name=<slug> [--push] ...`** → CLI 输出 `appId` + `appSecret`(+ `pushSecret` 若 push)plaintext 一次性 → 立即存 1Password 并发回 host。

### Phase 2:联调(1-2 周)

**最快路径** = 先走 cold-import 跑通 → 再开 pull/push:

| 步骤 | host 任务 | PAC 任务 | 验证标准 |
|---|---|---|---|
| 1 | 导出脱敏裸表(SELECT * → CSV)+ 数据字典 | — | 5 必填资源各导 100+ 行 |
| 2 | — | 基于字典写 `data/<host>/assemblers/*.yaml` | yaml 通过 zod 校验 |
| 3 | — | `pnpm cold-import` 装载 | `failed=0,facts>0` |
| 4 | (按需)实现资源 endpoint | — | PAC 拉 100 个 patient 成功 |
| 5 | (按需)实现 `/users:resolve` | — | byIds 查 10 个成功 |
| 6 | (可选)实现 push 接收 + HMAC | — | mock push → host 验签通过 → ACK |
| 7 | (可选)提供 actionUrls 模板 | — | PAC iframe 跳转成功 |

### Phase 3:灰度(1-2 周)

| host 任务 | 验证标准 |
|---|---|
| 1 家试点 clinic 开放生产数据 | PAC 接收 24h 数据,Persona / Plan 流水线跑通 |
| 客服培训(若用 PAC iframe)| 5-10 个客服在试点 clinic 试用 |
| 观察 first 100 召回结果 | 业务方点评算法准确性 / 话术质量 |

### Phase 4:全量接入(2-4 周)

| host 任务 | 验证标准 |
|---|---|
| 其他试点 clinic 接入 | 全量数据流入,无严重异常 |
| 生产监控接入(异常告警 / DW 同步) | host 侧 dashboard 看实时召回数据 |
| 凭证轮换演练 | apiKey / pushSecret 至少轮换 1 次,业务无感 |

## 10.2 host 提供给 PAC 团队的配置清单(模板)

```yaml
# === host 基本信息 ===
host_name:       friday        # 英文 slug
display_name:    Friday 牙科系统
contacts:
  tech:          ops@friday.example.com   # 技术联系
  business:      pm@friday.example.com    # 业务联系

# === 资源 endpoints ===
endpoints:
  patients:            https://friday.example.com/api/patients
  appointments:        https://friday.example.com/api/appointments
  clinical_encounters: https://friday.example.com/api/encounters
  charge_orders:       https://friday.example.com/api/charge-orders
  payments:            https://friday.example.com/api/payments
  # ... 其他按 host 能提供填(参见 §3.2)
  users:               https://friday.example.com/api/users/resolve   # /users:resolve 兼容

# === 鉴权(二选一)===
credentials:
  apiKey: "<plaintext;PAC 落库 scrypt hash>"
  # 或
  # loginUrl: https://friday.example.com/api/auth/login
  # username: pac-bot
  # password: "<plaintext>"

# === 金额 / 时区 ===
amountUnit:      yuan          # fen / yuan
timezone:        Asia/Shanghai

# === Push 接入(可选)===
push_enabled:    false         # true 时 PAC 会生成 pushSecret 发回 host

# === 字段映射(host → PAC canonical)===
# v2 起 host 不再直接交"fieldMapping yaml"清单 — 只需要提供数据字典(下面),
# PAC 团队根据字典写 data/<host-slug>/assemblers/*.yaml(每个 canonical 资源一份)。
# 数据字典示例(host 给 PAC 的 README):
#
# tb_patient:
#   含义: 患者主档
#   字段:
#     pid:            主键
#     real_name:      姓名
#     mobile:         手机号
#     sex:            性别(M/F)
#     dob:            生日(YYYY-MM-DD)
#     cust_status:    患者状态 0=active / 9=archived
#     is_blacklisted: 不打扰标记 0/1
#   关联: 无
#
# tb_encounter:
#   含义: 接诊主表
#   字段:
#     encounter_no:    主键
#     patient_pid:     关联 tb_patient.pid
#     enc_start_time:  接诊时间
#     ...
#   关联: tb_dx.encounter_id → tb_encounter.encounter_no (1:N)
#         tb_tx.encounter_id → tb_encounter.encounter_no (1:N)
#
# tb_tx:
#   含义: 治疗明细
#   字段:
#     tx_type 编码值:
#       A01 = 根管(endodontic)
#       A02 = 烤瓷冠(prosthodontic)
#       B01 = 正畸(orthodontic)
#       C01 = 种植(implant)
#       ...
#     tx_status: 0=in_progress / 1=completed
#     ...

# === actionUrls(可选,host UI 深链)===
actionUrls:
  VIEW_PATIENT:       "https://friday.example.com/patient/{patientId}"
  CREATE_APPOINTMENT: "https://friday.example.com/appointment/new?patientId={patientId}"
  VIEW_XRAY:          null

# === 试点 clinic 字典(用于 token 字典 + 数据隔离验证)===
clinics:
  - id: clinic_hz_dasha
    name: 杭州大厦门诊
  - id: clinic_hz_gaode
    name: 杭州高德门诊
```

PAC 团队拿到上面 yaml,1-2 天内完成 host 注册 + 配置录入。

## 10.3 接入后的 SLA + 运维

| 项 | host 责任 | PAC 责任 |
|---|---|---|
| **接入凭证** | 安全存储 appSecret / pushSecret;轮换演练 | 提供轮换 API |
| **数据完整性** | 资源 API 增量 cursor 单调 + 不丢漏 | 对账 cron 兜底发现漂移 |
| **API 可用性** | host 资源 API ≥ 99.5% / month | PAC 服务 ≥ 99.5% / month |
| **凭证有效性** | apiKey / pushSecret 过期前轮换 | 失效及时告警 |
| **数据修复** | 业务侧错误数据回滚 / 修正 | 自动从 raw_payload 重跑 parser |
| **版本变更** | 字段 / 资源 schema 变更**提前 2 周通知** PAC 团队 | mapping / parser 同步调整 |
| **故障联络** | 提供 7x24 oncall 联系方式 | 同上 |

## 10.4 验证 Checklist(给 host 自查)

接入交付前 host 自查:

- [ ] 11 个资源 endpoint 中至少 5 个必填实现(patients / appointments / clinical_encounters / charge_orders / payments)
- [ ] `/users:resolve` 双模式(byIds + list)实现
- [ ] 鉴权方式(apiKey 推荐 / loginUrl 兜底)凭证发给 PAC 团队
- [ ] 响应信封(`{ code, msg, data, details? }`)所有 endpoint 落地
- [ ] 增量 cursor 测试通过(从 cursor=null 起步 → 拉到 nextCursor=null 终止)
- [ ] 单患者 pull 接口实现(`?patientExternalId=...`)
- [ ] 金额单位 + 时区配置确认
- [ ] fieldMapping yaml 清单提交
- [ ] 试点 clinic 字典提交
- [ ] (若启用 push)HMAC 签名实现 + pushSecret 持有
- [ ] (若启用 push)source_event_id 幂等保证(同一 event 重试同 id)
- [ ] (推荐)actionUrls 模板提交
- [ ] 联系人 + oncall 渠道开通

---

> **文档主体完成(§1-10)**
> 附件:
> - `docs/api/pac-openapi-v0.json` — OpenAPI JSON spec
> - `docs/api/pac-redoc-v0.html` — 离线 Redoc 单页参考
>
> 接下来 v0.5 → v1.0 收敛:基于试点反馈微调,联调阶段持续更新。

