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

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

---

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

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

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

> 你**不需要**发 `subjectId` / `emrId` / 标准码 / 幂等键——原生行里有什么 PAC 用什么,关系和 ID 它自己补。

---

## 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` | ✅ | 发生时间(ISO8601 带时区;**没有独立发生时间就用记录创建时间**)|
| `updatedAt` | ✅ | 末次修改时间(内容变则变、重试不变 → 幂等/版本)|
| `subjectId` | ⭕ 选填 | 该记录自己的 id。**有就给(去重更准);没有 PAC 用自然键自己合成** |
| `clinicId` | ⭕ 条件 | 发生诊所(operational 事件给;patient 主档无)|

### 3.2 payload(按 subjectType)

**diagnosis 示例**(一次病历多条诊断 → `items[]`,`emrId` 在 payload):
```jsonc
{
  "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=原始诊断名(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[]` |

> 金额一律**整数分**(`amountCents`)+ `currency`;时间一律 **ISO8601 带时区**。

---

## 4. 幂等 / 防重(两形态都保证,你只管两件事)

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

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

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

---

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

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

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

---

## 6. 限制 / 约定

| 项 | 约定 |
|---|---|
| 批量 | 形态 C ≤ 500 events;形态 A 按批推荐 ≤ 数百行/请求 |
| 金额 / 时间 | 整数分 + currency;ISO8601 带时区(无时区拒收)|
| 编码 | UTF-8 |
| 重试 | 失败可安全重试(幂等);建议指数退避 |

---

## 7. 宿主接入 Checklist

- [ ] 拿到 PAC 分配的 `Host-Id` + `secret`
- [ ] 实现 HMAC 签名(`{ts}.{rawBody}`;同一串 body 签+发)
- [ ] **选形态**:能直接吐原生行 → A(给数据字典);否则 → C(对字段约定)
- [ ] 每条记录:身份稳定(有 `subjectId` 给,无则自然键稳定)、`updatedAt` 随内容变
- [ ] 多品牌每条带正确 `tenantId`
- [ ] 删除走软删 + `*_cancelled`
- [ ] 全程 HTTPS;失败重试

---

## 附:一条完整请求(形态 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"
```
