# PAC 宿主对接 v2(宿主开发视角)

> **一句话**:PAC 接收宿主方的患者业务数据,产出召回任务,**客服界面由 PAC 以 iframe / 独立链接形式提供**,宿主方自由放进自己系统的合适位置。
> **宿主方工作**:① 把数据提供给 PAC(三种通道选其一) ② 实现 `/users:resolve` ③ 嵌入 PAC iframe(由 PAC 提供链接)。
> **不需要**:写召回客服界面 / 调 PAC 业务 API / 学 PAC 内部概念 / 改字段命名 / 进 PAC 控制台。

---

## 1. 接入清单(先看这张表)

| 项 | 必/选 | 时机 | 说明 |
|---|---|---|---|
| Pull / Push 增量通道(§2.2 / 2.3,至少一种)| ✅ 必 | 对接期 | 实现资源 endpoint / 推 transaction 事件 |
| 接入鉴权 — 换票(§3)| ✅ 必 | 对接期 | host backend 调 `/auth/token` 拿 JWT |
| 实现 `/users:resolve`(§6)| ✅ 必 | 对接期 | PAC 分配召回任务给客服时调 |
| 凭证管理 + 运维 | ✅ 必 | 持续 | apiKey / pushSecret 安全存储 + 轮换 |
| Cold-import 存量历史数据(§2.1)| ✅ 必 | 上线时 | 一次性历史数据 dump |
| 嵌入 PAC iframe(§8)| ✅ 必 | 上线时 | PAC 给链接,宿主方 `<iframe>` 嵌入生产 UI |
| 提供 actionUrls 模板(§7)| ⚠️ 推荐 | 上线时 | 客服在 PAC iframe 内点"看患者档案"跳回宿主方 UI |

---

## 全局约定(贯穿全文)

### HTTP / envelope(适用所有 API:PAC 出口 + 宿主资源 endpoint + push 入口)
- **HTTP 状态码统一 `200`** — `5xx` 仅留给真崩溃(网关挂 / 服务挂),让 LB / k8s 探针识别死实例
- 响应信封:`{ "code": 0, "msg": "ok", "data": {...} }`
- 失败:`code` 非 0;`msg` 一句话给人看;可选 `details` 数组结构化诊断(如哪个字段校验不过)
- 业务异常**全部**通过 envelope `code` 区分(下文各处错误码表均沿用此约定,不再重复)

### 时间 / 金额归一化(统一走配置)

宿主侧**接入时一次性提供两个配置**:

```yaml
timezone:    Asia/Shanghai     # IANA 时区,解释无时区的 datetime
amount_unit: yuan              # yuan(元小数) / fen(分整数)
```

**所有入站路径**(Pull / Cold-import / Push)的时间和金额字段都**按这套配置由 PAC 归一化**,宿主侧**永远不用关心格式化** — 怎么存的怎么给就行。

PAC 出站 API 统一 ISO 8601 带时区 + 整数分(`amountCents`)。

---

## 2. 数据接入(存量 + 增量,缺一不可)

| 维度           | 通道              | 用途              | 宿主方做什么                          |
| ------------ | --------------- | --------------- | ------------------------------- |
| **存量(必)**    | **Cold-import** | 一次性导入历史数据(诊所全量) | `SELECT *` 导出 CSV/JSON + 数据字典   |
| **增量(至少一种)** | **Pull**        | 准实时             | 实现资源 endpoint(REST + 增量 cursor) |
| ↑ 同上         | **Push**(增强)    | 秒级              | 主动推 transaction 事件 + HMAC       |

**典型接入路径**:① Cold-import 装存量历史 → ② 同时接 Pull(或 Push)开始吃增量 → ③ (可选)再加 Push 把延迟降到秒级。
三种通道**数据形态完全一致**,共用同一份数据字典,切换零返工。

### 2.1 Cold-import — 存量历史数据导入

详见独立文档 [`host-data-request-checklist-v2.md`](host-data-request-checklist-v2.md) — 给宿主方的"一键导出"工作单。
宿主方交付:① CSV/JSON 裸表(每类业务一份) ② 数据字典(参考 [`host-data-dictionary-example.yaml`](host-data-dictionary-example.yaml))。

**所有宿主接入都要做一次**,把召回算法需要的历史背景灌进 PAC(种植 / 正畸 / 牙周等长周期场景没有 24-36 个月历史无法工作)。

### 2.2 Pull — 增量主流通道

详见 §4。**核心是实现资源 endpoint 清单(REST GET + 增量 cursor + envelope)**。

### 2.3 Push — 增量增强(可选)

详见 §5。**有事件总线 + 想秒级实时再做**,否则 pull 已够用。

---

## 3. 鉴权

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

```
host backend → POST /pac/v1/auth/token
  body: {
    appId:     "pac_friday_xxx",                  // PAC 一次性发给宿主方
    appSecret: "<宿主方持有的 plaintext>",
    user: {
      userId:     "u_001",                         // 🔴 宿主方的用户 id
      userName:   "张三",                           // 🔴 用户姓名(展示用)
      tenantId:   "t_friday",                      // 🔴 租户命名空间(接入后不能变)
      tenantName: "Friday 牙科集团",                // 🔴 租户中文名(展示用)
      role:       "staff" | "leader" | "admin",    // 🔴 PAC 框架固定 3 个角色
      clinics: [                                   // 🔴 该用户授权的诊所(id + name 都必填)
        { "id": "clinic_hz_dasha", "name": "杭州大厦门诊" },
        { "id": "clinic_hz_gaode", "name": "杭州高德门诊" }
      ]
    }
  }
  ↓
PAC response: {
  accessToken:   "<JWT, 2h TTL>",                  // host UI 直接用,Bearer 鉴权
  refreshToken: "<refresh JWT>",
  code:         "<60s 一次性 code,给 iframe 用>",
  expiresIn:    7200,
  codeExpiresIn: 60
}
```

**iframe 嵌入**:把 `code` 拼到 iframe URL `https://pac.example.com/embed?code=XXX`,PAC 前端自己拿 code 换 token。**不要直接把 accessToken 放 URL**。

### 3.2 Name 携带要求

PAC 不存宿主方的 user / tenant / clinic 主数据,**展示用的 name 由换票时一次性附带,带满当前用户能看到的全部范围**:

| 字段 | 必填 | 缺失影响 |
|---|---|---|
| `user.userName` | 🔴 | iframe 顶部 / 操作日志显示 user id 字面值 |
| `user.tenantName` | 🔴 | 多租户场景头部展示空白 |
| `user.clinics[].name` | 🔴 | 诊所筛选器 / 列表只显示 clinic id 字面值 |

**生命周期**:跟 access token 同寿命(2h)。PAC 前端缓存使用,token 刷新时一并刷新。

**name 覆盖不到的边缘场景**(如历史召回的离职 assignee 名字)→ PAC 后端调宿主方的 `/users:resolve` 兜底(§6)。

### 3.3 凭证

| 凭证 | 用途 | 安全要点 |
|---|---|---|
| `appId` + `appSecret` | host backend 换 JWT | plaintext 一次性发给宿主方,PAC 永不回读 |
| `pushSecret`(可选)| Push 路径 HMAC | 同上;支持多 key 平滑轮换 |
| `apiKey`(宿主方颁发给 PAC)| PAC 调宿主方资源 API | 宿主方颁发,推荐 Bearer 形式 |

### 3.4 错误码

| code | 含义 |
|---|---|
| `0` | 成功 |
| `10101` | appId / appSecret 错 |
| `10102` | 一次性 code 失效(超 60s 或已用) |
| `10103` | refresh token 失效 |
| `10104` | 缺 access token |
| `10105` | access token 过期 |
| `10106` | access token 无效 |
| `10107` | 权限不足(role / clinic 越界) |

---

## 4. Pull 路径细节(实现资源 endpoint)

### 4.1 资源 endpoint 清单

每个 endpoint 一张 host 侧表 / 视图行直出,字段命名 / 嵌套 / 枚举编码**宿主方自由**,PAC 侧 yaml 映射。
**14 个 endpoint 跟 [`host-data-request-checklist-v2.md`](host-data-request-checklist-v2.md) §1 的数据类别一一对齐**(同一份数据字典两路通用)。

| Endpoint key          | 含义                              | 必/选             |
| --------------------- | ------------------------------- | --------------- |
| `patients`            | 患者主档                            | 🔴 必            |
| `appointments`        | 预约 + 状态变更                       | 🔴 必            |
| `clinical_encounters` | 医生接诊(可嵌套 1:N 子表)                | 🔴 必            |
| `emr_documents` ⭐     | 电子病历(诊断 / 治疗主要载体)               | 🔴 必            |
| `image_studies` ⭐     | 影像 metadata + 医生 finding(无文件本体) | 🔴 必            |
| `orders`              | 收费单 / 医嘱(治疗 / 耗材 / 药品)+ 明细       | 🔴 必            |
| `payments`            | 收款                              | 🔴 必            |
| `recharges`           | 充值 / 储值 / 福利卡                   | 🟠 强烈推荐(精确 LTV) |
| `complaints`          | 投诉                              | 🟠 强烈推荐(合规硬约束)  |
| `referrals`           | 转介绍                             | 🟠 强烈推荐(渠道归因)   |
| `refunds`             | 退费                              | 🟡 可选           |
| `consultations`       | 咨询                              | 🟡 可选           |
| `visit_registrations` | 挂号                              | 🟡 可选           |
| `visit_receptions`    | 接待登记                            | 🟡 可选           |

### 4.2 统一响应 envelope

```jsonc
// 成功
{
  "code": 0,
  "msg":  "ok",
  "data": {
    "items":      [ ...资源对象数组... ],
    "nextCursor": "string | null"      // null = 没有更多
  }
}

// 失败
{ "code": 10101, "msg": "...", "data": null, "details": null }
```

- 每页 100-200 条,上限 500
- 业务异常通过 envelope `code` 区分(全局约定见首页;建议复用 PAC code 表方便联调日志对齐)

### 4.3 增量游标

```
GET <endpoint>?after=<cursor>&limit=200
```

- Cursor 形态:ISO 8601 时间戳 或 宿主方自定义 opaque string
- **必须单调递增**(不能跳)
- 首次拉取 `cursor=null`(从最早开始)
- 推荐 host 关键资源**逻辑删除 + 更新 `updatedAt`**,避免物删产生"幻读"

### 4.4 单患者即时 pull

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

返回该患者所有相关资源的**当前态**(不限增量)。PAC 侧 30s dedupe + 60s 冷却,宿主方不用担心被刷爆。

### 4.5 错误处理

| 场景 | 宿主方返回 | PAC 行为 |
|---|---|---|
| 正常 | `code=0` + items + nextCursor | 消化 + 推 cursor |
| 已最新无更多 | `code=0` + `items=[]` + `nextCursor=null` | 跳过到下个 cron |
| 鉴权失败 | `code=10101` | 触发凭证告警,暂停拉取 |
| 单患者找不到 | `code=10301` | 标 archived |
| 限流 | `code=10003` | 指数退避重试 |
| 内部异常 | `code` ∈ `90xxx` 或 HTTP `5xx` | 重试 3 次 → partial |

---

## 5. Push 路径(可选,有事件总线再做)

### 5.1 Endpoint + 报文

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

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

Body:
{
  "events": [
    {
      "sourceEventId":     "host-evt-88123",     // ⭐ 必填,宿主方原生事件 id,幂等键
      "clinicId":          "clinic_hz_dasha",    // ⭐ 必填
      "patientExternalId": "P00012345",          // 可空(管理类事件)
      "action":            "appointment_created",// ⭐ 必填,见下表
      "subjectType":       "appointment",        // ⭐ 必填,见下表
      "subjectId":         "apt_001",            // ⭐ 必填,宿主方侧业务对象 id
      "rawPayload":        { /* 见下方 rawPayload 说明 */ },
      "occurredAt":        "2026-05-10 14:30:00"           // ⭐ 必填,宿主原生时间字符串(按宿主配置的 timezone 解释)
    }
    // 单次最多 500 个,推荐 50-100
  ]
}
```

**`rawPayload` 形态约定**:
- **JSON object**(不接受数组 / 字符串)
- 内容 = 宿主侧**该业务对象当前态的完整快照**(类比 pull endpoint 返回的单个 `item`)
- 字段命名 / 嵌套形态 / 枚举编码**宿主方自由** — PAC 侧 yaml 映射(跟 pull 共用同一份数据字典)
- 不需要按 PAC canonical 改字段名;不需要加工 / 脱敏 / 翻译

**多表场景例子** — 假设宿主侧"患者"由 4 张表组成(主档 + 联系方式 + 标签 + 会员卡),Pull endpoint 返回 / Push rawPayload / Cold-import 单行都是**同一个形态** — 主表行 + 关联子表数组嵌套成一个 JSON object:

```jsonc
{
  // ── 主表 tb_patient 一行 ──
  "pid":          "P00012345",
  "real_name":    "张三",
  "sex":          "M",
  "dob":          "1985-03-12",
  "cust_status":  "0",                          // 0=active / 9=archived

  // ── 1:N 联系方式 tb_patient_contact 数组嵌套 ──
  "contacts": [
    { "type": "MOBILE", "value": "138****1234", "is_primary": 1 },
    { "type": "WECHAT", "value": "zhangsan_wx", "is_primary": 0 }
  ],

  // ── 1:N 标签 tb_patient_tag 数组嵌套 ──
  "tags": [
    { "tag_code": "VIP", "tag_name": "VIP 客户" },
    { "tag_code": "HARD_TALK", "tag_name": "难沟通" }
  ],

  // ── 1:1 会员卡 tb_patient_member 嵌套 ──
  "member": {
    "level":    2,                              // 1=银 / 2=金 / 3=钻
    "balance":  3200.00,                        // 单位按 amount_unit 配置解释
    "since":    "2024-08-15"
  }
}
```

> 注:`patient` 在 push 路径下不是 transaction subject(详见 §5.2 表),本例展示**通用形态**;实际 push 多表场景多见于 `clinical_encounter`(接诊主表 + 诊断 / 治疗子表)、`order`(收费单主表 + 明细)等。

**大小限制**:单条 rawPayload ≤ **64 KB**;超过先剔除非业务字段(如 base64 文件 / 大段 EMR 自由文本)。

### 5.2 Action / SubjectType 枚举

| `action`                                | `subjectType`        | 业务时机       |
| --------------------------------------- | -------------------- | ---------- |
| `consultation_created`                  | `consultation`       | 咨询录入       |
| `appointment_created/changed/cancelled` | `appointment`        | 预约创建/改/取消  |
| `appointment_fulfilled`                 | `appointment`        | 患者到诊(预约履约) |
| `visit_registered`                      | `visit_registration` | 挂号         |
| `reception_completed`                   | `visit_reception`    | 前台接待完成     |
| `doctor_encounter_started`              | `clinical_encounter` | 医生接诊       |
| `order_submitted`                       | `order`              | 收费单/医嘱提交   |
| `payment_received`                      | `payment`            | 收款         |
| `refund_created`                        | `refund`             | 退费         |
| `emr_submitted/rejected`                | `emr_document`       | 病历提交/打回    |
| `image_uploaded`                        | `image_study`        | 影像上传       |
| `recharge_received`                     | `recharge`           | 充值/办卡      |
| `complaint_filed/resolved`              | `complaint`          | 投诉提交/结案    |
| `referral_recorded`                     | `referral`           | 转介绍登记      |
|                                         |                      |            |

### 5.3 HMAC 签名

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

PAC 验签时检查 timestamp 跟当前差 < 5 分钟(防 replay)。**密钥轮换支持多 key 平滑切换**,无停机。

### 5.4 错误响应

**整批原子** — 要么全成功(`code=0`),要么整批拒绝(`code` 非 0,宿主修正后**整批重发**,幂等保护重复不会写)。

| code | 含义 | 宿主方怎么办 |
|---|---|---|
| `0` | 全部受理 | OK |
| `10001` | 报文结构错 | 检查报文 |
| `10002` | 字段校验失败,看 `details` 知道哪几条 | 修正后整批重发 |
| `10003` | 限流 | 指数退避 |
| `10108` | HMAC 验签失败 | 检查 secret / 算法 |
| `10109` | 时间戳过期(> 5 min) | 同步本地时钟 |

**幂等保证**:同 `sourceEventId` 重发 PAC **当成功处理**,宿主方不用特殊处理。

---

## 6. `/users:resolve`(宿主方提供给 PAC)

PAC 自动分配召回任务时调宿主方查可用客服。**必填**。

### 6.1 端点 + 鉴权

`POST <宿主方的 /users:resolve URL>`,鉴权用同一套 apiKey(跟 §4 资源 endpoint 一致)。

### 6.2 请求

```jsonc
{
  // 模式 A — list:按条件查列表(分配候选用)🔴 必实现
  "list": {
    "tenantId":  "t_friday",          // 🔴 必填
    "role":      "staff",              // 🟢 可选
    "clinicIds": ["clinic_hz_dasha"],  // 🟢 可选
    "active":    true                  // 🟢 可选,默认 true
    // "cursor": "..."                 // v2+ 分页 hook 预留(v1 不用,响应 ≤ 500 已足够)
  },

  // 模式 B — byIds:按 id 批量解析(展示用)⚠️ 推荐实现
  "byIds": ["u_001", "u_002"]
}
```

两模式**互斥**(单次填一个)。

**byIds 为何是推荐而非必实现**:§3.2 name 携带已硬要求(`userName` + `clinics[].name`),覆盖绝大多数展示场景。byIds 只用于边缘场景(如历史 plan 离职 assignee 展示)— 不实现的话 PAC 兜底显示 user id 字面值,业务流转不受影响。

### 6.3 响应

```jsonc
{
  "code": 0, "msg": "ok",
  "data": {
    "users": [
      {
        "id":        "u_001",        // 🔴 必填
        "name":      "张三",          // 🔴 必填
        "role":      "staff",         // 🔴 必填
        "clinicIds": ["clinic_hz_dasha"], // 🔴 必填
        "active":    true,            // 🔴 必填(离职 user 也要能解析到,active=false)
        "workload":  3                // 🟢 可选(未完成召回数,PAC 负载均衡用)
      }
    ]
  }
}
```

### 6.4 性能

- 响应 **< 500ms**(PAC 每个 Plan 重算调 1-3 次)
- 单次 `byIds` ≤ 200
- 单次 `list` 响应 `users` ≤ 500
- PAC 侧 5 分钟 LRU 缓存

---

## 7. actionUrls(可选,deep-link 模板)

提供一组跳转 URL 模板,PAC 前端在召回卡片点"看患者档案"等按钮时跳回宿主方的 UI。

```jsonc
{
  "VIEW_PATIENT":       "https://host.example.com/patient/{patientId}",
  "VIEW_EMR":           "https://host.example.com/emr?patientId={patientId}",
  "CREATE_APPOINTMENT": "https://host.example.com/appointment/new?patientId={patientId}",
  "VIEW_XRAY":          null,                  // null = 不支持,按钮不渲染
  "CALL_PATIENT":       null
}
```

**只提供宿主方能支持的**,不写或填 `null` 都行。占位符 `{patientId}` / `{appointmentId}` / `{emrId}` / `{clinicId}` / `{userId}` 按字面替换。

**跨域登录态**:推荐宿主方用 cookie 跨域共享(跳过去自动登录),或在跳转 URL 加短时 token。

---

## 8. iframe 嵌入(客服界面接入方式)

**现阶段唯一接入方式**:PAC 提供客服工作台 URL,宿主方用 `<iframe>` 嵌入自己系统合适位置(如客服后台某个 tab / 主管 dashboard 一块区域)。
召回列表、患者画像、话术、摘要、执行回写表单等所有客服操作 UI **由 PAC 提供**,宿主方不写也不调 PAC 业务 API。

### 8.1 嵌入步骤

1. host backend 跑换票流程(§3.1),拿到 `code`(一次性,60s)
2. iframe 的 `src` 拼接 code:
   ```html
   <iframe
     src="https://pac.example.com/embed?code=XXX&view=plans"
     sandbox="allow-scripts allow-same-origin allow-top-navigation"
     style="width:100%; height:100%; border:0;">
   </iframe>
   ```
3. PAC 前端拿 code 换 access token,自启动

### 8.2 PAC 提供的 view(URL `?view=` 参数)

| view | 用途 |
|---|---|
| `plans` | 客服召回任务列表 + 详情(默认) |
| `plans/{id}` | 直接打开某个 plan |
| `patient/{externalId}` | 患者画像页 |
| `dashboard` | 主管 dashboard(团队执行概览) |

具体 view 清单接入时再约,**新增 view 无需 host 改代码**。

### 8.3 嵌入约束

- 浏览器 CSP:宿主方配置 `frame-ancestors <宿主方 UI 域名>`(PAC 接入时约定)
- iframe `sandbox` 最小权限(上面示例已含)
- **不依赖 postMessage** 跟 PAC 通信 — 状态全走 PAC 后端,iframe 是纯渲染容器
- 客服界面响应式适配,移动端 / 桌面端均可

### 8.4 (可选)PAC 开放 REST API

完整 API 仅作 **未来扩展 / 运维 / 第三方对接** 预留,现阶段宿主方不需要调。Swagger:`https://<pac-host>/api/docs`。

---

## 9. 接入流程 + Checklist

### 9.1 4 阶段

| Phase | 宿主方做 | 验证 |
|---|---|---|
| **1. 注册** | 提供联系人 / 试点 clinic 清单 / 接入模式选择 | PAC 团队跑 CLI 创 host,回发 appId + appSecret |
| **2. 联调** | 走 cold-import 先跑通 → 再开 pull / push | `failed=0, facts>0` |
| **3. 灰度** | 1 家试点 clinic 开放生产数据 + iframe 嵌入到客服界面 + 客服培训 | 24h 数据流入,前 100 召回业务方点评 |
| **4. 全量** | 其他试点 clinic 接入 + 凭证轮换演练 | 全量数据无严重异常 |

### 9.2 验证 Checklist(交付前自查)

#### 必填
- [ ] 鉴权:apiKey 或 loginUrl 凭证发给 PAC 团队
- [ ] 数据通道:cold-import 文件交付 **或** 5 个必填 endpoint 实现
- [ ] `/users:resolve` 双模式(byIds + list)实现
- [ ] 所有 endpoint 响应走统一 envelope(`{ code, msg, data, details? }`)
- [ ] 增量 cursor 测试通过(`cursor=null` → `nextCursor=null`)
- [ ] 单患者 pull 实现(`?patientExternalId=...`)
- [ ] amountUnit + timezone 配置确认
- [ ] 试点 clinic 字典提交
- [ ] 联系人 + oncall 渠道开通

#### 可选
- [ ] Push:HMAC 签名实现 + `sourceEventId` 幂等
- [ ] actionUrls 模板提交
- [ ] 跨域单点登录方案确认

---

## 10. SLA + 运维

| 项 | 宿主方责任 | PAC 责任 |
|---|---|---|
| 凭证 | 安全存储 appSecret / pushSecret;按期轮换 | 提供轮换 CLI |
| 数据完整性 | 资源 API 增量 cursor 单调,关键资源逻辑删除 | 对账 cron 兜底 |
| API 可用性 | host 资源 API ≥ 99.5% / month | PAC 服务 ≥ 99.5% / month |
| 字段 / schema 变更 | **提前 2 周通知** PAC | 同步调整 yaml / parser |
| 故障 | 7×24 oncall 联系方式 | 同上 |

---

**联系**:对接细节 / 字段含义 / 故障排查,直接找 PAC 接入对接人。
