# PAC Push 接入(宿主推送)对接文档

> **给宿主开发**:把患者事实**实时推**给 PAC。你只发业务系统里**本来就有的字段**;
> 标准码映射 / 幂等键 / 内部 ID 合成 / 外键关联 / 时区归一 **全部 PAC 内部做**。
> **必须 HTTPS**。配套 Pull(PAC 主动拉)。

---

## 0. 两种接入形态(二选一)

|               | **形态 A:推原生行(推荐)**        | **形态 C:推结构化事件(备选)**           |
| ------------- | ------------------------ | ----------------------------- |
| 你发什么          | 你导出给数仓的那种**原生表行,照抄**     | PAC 约定的事件(envelope + payload) |
| 你要懂           | 只懂**自己的数据字典**            | 还要懂 PAC 的字段/类型约定              |
| 拆分 / 关联 / 码映射 | **全 PAC**                | payload 的码仍 PAC 映射,其余你排好      |
| 一次性成本         | 给一份**数据字典**,PAC 写映射 yaml | 对一遍字段约定                       |
| 适合            | **绝大多数**(诊所 / HIS / 老系统) | 有数据中台、愿稳定吐标准格式的强宿主            |

**默认选 A**:字段最少、口径与数仓一致;PAC 以后改映射字典能**自愈历史**(无需你重发)。
鉴权(§1)两形态通用;防重(§4)两形态都保证。

---

## 1. 鉴权(HMAC-SHA256,两形态通用)

| Header            | 值                                                       |
| ----------------- | ------------------------------------------------------- |
| `X-PAC-Host-Id`   | PAC 分配的 host UUID(也表明"你是谁")                             |
| `X-PAC-Timestamp` | 当前 Unix 秒(容忍 ±5 分钟,防重放)                                 |
| `X-PAC-Signature` | `hex( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) )` |

- `secret`:接入时 PAC 一次性安全下发,双方各存原文,永不上网。
- ⚠️ **"用来签的 body" 与 "发出去的 body" 必须是同一串字节**(先 `JSON.stringify` 一次,签它、发它)。
- 密钥轮换:PAC 给新 secret,过渡期新旧都收,你切完旧的作废(零停机)。

```js
const body = JSON.stringify(payload);
const ts   = Math.floor(Date.now()/1000);
const sig  = require('crypto').createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex');
// headers: X-PAC-Host-Id / X-PAC-Timestamp=ts / X-PAC-Signature=sig ; body 原样发
```

---

## 2. 形态 A:推原生行(推荐)

```jsonc
POST /pac/v1/push/rows
{
  "tenantId": "123456",                    // 硬隔离边界;多品牌必填,单租户可由 PAC 配置兜底
  "source":   "fact_emr_treatment_out",    // 哪张源表(= 你给数据字典里的表名)
  "rows": [
    { /* 你那张表的一行,原生字段照抄,含嵌套结构(如 diag 数组)都不用拆 */ }
  ]
}
```

**对原生行的唯一要求**(都是你表里本来就有的列):
- 有患者引用列(如 `patient_id`);
- 有**末次修改时间**列(如 `updated_date`)——驱动幂等/版本;
- 有该行的自然键(如 `id`)。

PAC 收到后(用你一次性提供的数据字典生成的 yaml):**拆分**(一行 EMR → 病历 + 多条诊断 + 治疗,外键自动挂)→ **诊断名映射标准码** → **合成幂等键去重** → 落库。

---

## 3. 形态 C:推结构化事件

```jsonc
POST /pac/v1/push/events
{ "events": [ /* 1~500 条;可混不同 subjectType;顺序无要求 */ ] }
```

每条 event = **envelope(公共)** + **payload(资源专属)**。

### 3.1 envelope 公共字段

| 字段            | 必填   | 说明                                                                   |
| ------------- | ---- | -------------------------------------------------------------------- |
| `subjectType` | ✅    | 资源类型:`diagnosis`/`treatment`/`appointment`/`payment`/`emr`/…         |
| `action`      | ✅    | 动作:`*_recorded` / `*_created` / `*_changed` / `*_cancelled`(撤回,见 §5) |
| `tenantId`    | ✅    | 硬隔离(多品牌必填;`clinicId` 替代不了)                                           |
| `patientId`   | ✅    | 患者号                                                                  |
| `occurredAt`  | ✅    | 发生时间(**本地时间即可**,PAC 按你配置的时区归一;带 offset 也行;**无独立发生时间就用创建时间**)        |
| `updatedAt`   | ✅    | 末次修改时间(内容变则变、重试不变 → 幂等/版本)                                           |
| `subjectId`   | ⭕ 选填 | 该记录自己的 id。**有就给(去重更准);没有 PAC 用自然键自己合成**                              |
| `clinicId`    | ⭕ 条件 | 发生诊所(operational 事件给;patient 主档无)                                    |

### 3.2 payload(按 subjectType)

**diagnosis 示例**(一次病历多条诊断 → `items[]`,`emrId` 在 payload):
```jsonc
{
  "subjectType":"diagnosis", "action":"diagnosis_recorded",
  "tenantId":"123456", "patientId":"1328199", "clinicId":"7d49539c",
  "occurredAt":"2025-12-06T13:51:44+08:00", "updatedAt":"2025-12-25T18:38:21+08:00",
  "payload": {
    "emrId": "16bee63f...",          // 关联电子病历(诊断/治疗/建议有;预约/收费无)
    "doctorId":"5725", "doctorName":"叶谦",
    "items": [
      { "name":"菌斑性龈炎", "tooth":"",      "code":"" },   // name=原始诊断名(PAC 映射码);code 选填;tooth 选填
      { "name":"恒牙深龋",   "tooth":"17;37", "code":"K02.800x008" }
    ]
  }
}
```

**其它资源 payload 速查**:
| subjectType | action 例 | payload 关键字段 |
|---|---|---|
| `emr`| `emr_recorded` | `doctorId/doctorName, illnessDesc, examFindings, disposal, doctorAdvice` |
| `treatment` | `treatment_recorded` | `emrId, items[]:{ category, subtype, tooth, status, startedAt, completedAt }` |
| `recommendation` | `recommendation_recorded` | `emrId, items[]:{ name, tooth, windowDays }` |
| `appointment` | `appointment_created/changed/cancelled` | `scheduledAt, status, complaintCategory`(建议带 `subjectId`=预约 id)|
| `payment`/`recharge`/`refund` | `*_recorded` | `amountCents, currency`(建议带 `subjectId`)|
| `consultation` | `consultation_recorded` | `intentCategories[]` |

> **金额、时间按你系统原样发,不用自己转**:接入时配置 `amountUnit`(fen/yuan)+ `timezone`(IANA),
> PAC 自动归一成「整数分 + UTC」(同数仓口径)。金额带 `currency`;时间本地值即可。

---

## 4. 幂等 / 防重

PAC **自己合成幂等键**。你只要:
1. **身份稳定**:有 `subjectId` 就给;没有,保证自然键稳定(诊断 = `emrId+name+tooth`);
2. **`updatedAt` （更新时间字段）正确**:内容变它变、重试不变。

| 你的行为 | PAC 结果 |
|---|---|
| 网络抖动**重推一模一样的** | **自动跳过**(不重复)|
| 改了内容**重推** | 自动**升版本**(旧版作废)|
| 同请求里夹重复条目 | 自动折叠 |

**保证机制**(已实现):`partial UNIQUE(host, tenant, source_event_id)` + fact 内容指纹**双层**——即使你失误重推完全相同两条,也**绝不产生重复**。**你无需自己保证事件 id 唯一。**(形态 A 即pull同款管线,幂等已生产实证。)

---

## 5. 撤回 / 删除(必须软删)⚠️

PAC 靠你**发事件**才知道——**物理删除永远感知不到**。所以:
- **关键资源必须逻辑删(软删),别物理删**;
- 撤回:**重发该记录、`action=*_cancelled`(如 `diagnosis_cancelled`)+ 更新 `updatedAt`**;
- 诊断这类 `items[]`:撤回某条就发**该条**(同 `emrId+name+tooth`)的 `*_cancelled`;
- PAC 把对应记录置"已取消",不再参与召回。

(与 Pull 通道一致:关键资源需逻辑删除并更新 `updatedAt`。)

---

## 6. 可靠投递 / 异常 / 漏推(宿主侧)

### 6.1 回执:看 envelope `code`,**不是 HTTP 状态**

PAC 统一返回 `{ code, msg, data }`,**HTTP 恒 200**(只有进程级崩溃才 5xx)。判成败**看 `code`**:

| 场景                 | HTTP    | `code`  | 重试?                                                                     |
| ------------------ | ------- | ------- | ----------------------------------------------------------------------- |
| 成功                 | 200     | `0`     | — `data`={syncLogId, accepted, transactionsWritten, duplicates, failed} |
| 验签失败(secret/时钟/签名) | 200     | `10106` | ❌                                                                       |
| 字段校验失败             | 200     | `10002` | ❌                                                                       |
| 请求格式错              | 200     | `10001` | ❌                                                                       |
| 数据形态漂移(字段/结构对不上)   | 200     | `30802` | ❌                                                                       |
| 限流                 | 200     | `10003` | ✅                                                                       |
| PAC 内部错            | 200     | `9xxxx` | ✅                                                                       |
| PAC 崩溃             | **500** | `9xxxx` | ✅                                                                       |
| 超时 / 连不上           | —       | —       | ✅                                                                       |

> **规则**:`0` 成功;`10003`/`9xxxx`/HTTP 5xx/超时 → **退避重试**。**不确定也重试**，发了没收到响应 → 直接重试。

### 6.2 漏推(整条没推上来)怎么补 —— reconcile

push 可能整条漏(宿主 bug / 没入队 / 崩溃没持久化):

1. **周期幂等补推(纯 push)**:每晚把最近 N 天数据**全量重推一遍**——已推跳过、漏的补上、改的升版本。这就是 push 侧对账,定时无脑跑;
2. **PAC 侧检测**:push 量异常下降 / 某 host 太久没推 → PAC 告警 → 人工触发补推。

---

## 7. 限制 / 约定

| 项 | 约定 |
|---|---|
| 批量 | 形态 C ≤ 500 events;形态 A 按批推荐 ≤ 数百行/请求 |
| 金额 / 时间 | **按你系统原样发**;PAC 按接入配置(`amountUnit`+`timezone`)归一成 分/UTC。带 offset 的时间直接用 |
| 编码 | UTF-8 |
| 重试 | 失败可安全重试(幂等);建议指数退避 |

---

## 8. 宿主接入 Checklist

- [ ] 拿到 PAC 分配的 `Host-Id` + `secret`
- [ ] 实现 HMAC 签名(`{ts}.{rawBody}`;同一串 body 签+发)
- [ ] **选形态**:能直接吐原生行 → A(给数据字典);否则 → C(对字段约定)
- [ ] 每条记录:身份稳定(有 `subjectId` 给,无则自然键稳定)、`updatedAt` 随内容变
- [ ] 多品牌每条带正确 `tenantId`
- [ ] 删除走软删 + `*_cancelled`
- [ ] 接入时配置 `amountUnit`(fen/yuan)+ `timezone`(IANA)
- [ ] 全程 HTTPS
- [ ] **本地 outbox 持久化 + 异步 worker**(业务写库与入队同事务)
- [ ] 失败/异常**按 `body.code` 族判重试**
- [ ] **周期幂等补推**最近 N 天(纯 push 宿主兜底漏推)

---

## 附:一条完整请求(形态 C,curl)

```bash
TS=$(date +%s)
BODY='{"events":[{"subjectType":"diagnosis","action":"diagnosis_recorded","tenantId":"ruier","patientId":"1328199","clinicId":"7d49539c","occurredAt":"2025-12-06T13:51:44+08:00","updatedAt":"2025-12-25T18:38:21+08:00","payload":{"emrId":"16bee63f","doctorId":"5725","doctorName":"叶谦","items":[{"name":"菌斑性龈炎","tooth":"","code":""},{"name":"恒牙深龋","tooth":"17;37","code":"K02.800x008"}]}}]}'
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "$PAC/pac/v1/push/events" -H "Content-Type: application/json" \
  -H "X-PAC-Host-Id: $HOST_ID" -H "X-PAC-Timestamp: $TS" -H "X-PAC-Signature: $SIG" --data "$BODY"
```
