---
title: Push 通道
description: 宿主侧实时推送患者事实到 PAC 的对接文档,含两种接入形态、HMAC 鉴权与幂等防重。
icon: Send
---

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

---

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

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

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

---

## 1. 组织定位(两形态通用)

每条数据用三个 id 标识归属,**与 [登录授权](/docs/integration/auth-login) 同一套组织 id**:

| 字段 | 含义 | 取值 |
|---|---|---|
| `tenantId` | 集团 id | 同登录授权的 `tenantId`。单集团宿主用固定值,多集团按数据归属传 |
| `sourceUnit` | 命名空间 id(品牌 / 区域等) | 集团内给患者号消歧:同号不同命名空间 = 不同人。单一命名空间可省 |
| `clinicId` | 诊所 id | 业务发生诊所 |

- **形态 A**:就是原生行里的对应列,数据字典指明哪列即可。
- **形态 C**:用上面的字段名显式带(见 §4.1)。

---

## 2. 鉴权(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 原样发
```

---

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

```jsonc
// POST /pac/v1/push/rows
{
  "source": "fact_emr_treatment_out",      // 哪张源表(= 你给数据字典里的表名)
  "rows": [
    { /* 你那张表的一行,原生字段照抄,含嵌套结构(如 diag 数组)都不用拆;
         组织列(集团 / 命名空间 / 诊所)就在行里,数据字典指明即可 —— 见 §1 */ }
  ]
}
```

**对原生行的唯一要求**(都是你表里本来就有的列):
- 有患者引用列(如 `patient_id`);
- 有**组织归属列**(集团 / 命名空间 / 诊所,如 `brand` / `clinic_id`)—— 对应 §1 三个 id,单集团 + 单命名空间可省到只剩诊所;
- 有**末次修改时间**列(如 `updated_date`)—— 驱动幂等 / 版本;
- 有该行的自然键(如 `id`)。

PAC 收到后据你的数据字典自动**拆分**(一行 EMR → 病历 + 多条诊断 + 治疗,外键自动挂)→ **诊断名映射标准码** → **去重入库**。拆分 / 映射 / 去重你都不用做。

---

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

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

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

> 形态 C 已可用;多数宿主用 **形态 A 更省事**(字段最少、口径与数仓一致)。

### 4.1 envelope 公共字段

| 字段            | 必填   | 说明                                                                   |
| ------------- | ---- | -------------------------------------------------------------------- |
| `subjectType` | ✅    | 资源类型 —— 全量见 [事件枚举](/docs/event-enums) |
| `action`      | ✅    | 动作 —— 与 `subjectType` 成对,全量见 [事件枚举](/docs/event-enums) |
| `subjectId`   | ✅    | 本记录在你系统的 id(每次同一记录同值,进幂等键)                                          |
| `patientId`   | ⭕ 条件 | 所属患者 id;`subjectType=patient`(主档自身)时可省                               |
| `tenantId`    | ✅    | 集团 id(同 §1)。单集团固定值,多集团按归属传                                           |
| `sourceUnit`  | ⭕ 条件 | 命名空间 id(同 §1)。多命名空间必带,单一可省                                           |
| `clinicId`    | ✅    | 诊所 id                                                               |
| `occurredAt`  | ✅    | 发生时间(本地时间即可,PAC 按配置时区归一;带 offset 也行;无独立发生时间用创建时间)                   |
| `updatedAt`   | ⭕ 推荐 | 末次修改时间(内容变则变、重试不变 → 升版本 / 去重);不传则回退 `occurredAt`                    |

### 4.2 payload(按 subjectType)

**diagnosis 示例**(一次病历多条诊断 → `items[]`,`emrId` 在 payload):

```jsonc
{
  "subjectType":"diagnosis", "action":"diagnosis_recorded", "subjectId":"dx_88f1",
  "patientId":"P10086", "tenantId":"grp_001", "sourceUnit":"B001", "clinicId":"C001",
  "occurredAt":"2025-12-06T13:51:44+08:00", "updatedAt":"2025-12-25T18:38:21+08:00",
  "payload": {
    "emrId": "emr_88f1",             // 关联电子病历(诊断/治疗/建议有;预约/收费无)
    "doctorId":"D5725", "doctorName":"（医生名）",
    "items": [
      { "name":"菌斑性龈炎", "tooth":"",      "code":"" },   // name=原始诊断名(PAC 映射码);code 选填;tooth 选填
      { "name":"恒牙深龋",   "tooth":"17;37", "code":"K02.800x008" }
    ]
  }
}
```

**各主体 payload 的标准化字段**(全量)见 [主体标准化字段](/docs/canonical-fields);`action` 取值见 [事件枚举](/docs/event-enums)。

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

---

## 5. 幂等 / 防重

PAC **自己合成幂等键**。你只要:
1. **身份稳定**:`subjectId` 同一条记录每次同值(没有现成 id 就用稳定自然键,如 `emrId+name+tooth` 拼一个);
2. **`updatedAt`(更新时间字段)正确**:内容变它变、重试不变。

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

只要满足上面两条,**PAC 保证幂等**——重推完全相同的不会重复、改了内容的自动升版本;**你无需自己保证事件 id 唯一**。

---

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

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

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

---

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

### 7.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 / 超时 → **退避重试**。**不确定也重试**,发了没收到响应 → 直接重试。

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

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

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

---

## 8. 限制 / 约定

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

---

## 9. 宿主接入 Checklist

- [ ] 拿到 PAC 分配的 `Host-Id` + `secret`
- [ ] 实现 HMAC 签名(`{ts}.{rawBody}`;同一串 body 签+发)
- [ ] **选形态**:能直接吐原生行 → A(给数据字典);否则 → C(对字段约定)
- [ ] 每条记录:身份稳定(有 `subjectId` 给,无则自然键稳定)、`updatedAt` 随内容变
- [ ] 每条带正确组织 id(`tenantId` 集团、`sourceUnit` 命名空间、`clinicId` 诊所;取值同登录授权)
- [ ] 删除走软删 + `*_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","subjectId":"dx_88f1","patientId":"P10086","tenantId":"grp_001","sourceUnit":"B001","clinicId":"C001","occurredAt":"2025-12-06T13:51:44+08:00","updatedAt":"2025-12-25T18:38:21+08:00","payload":{"emrId":"emr_88f1","doctorId":"D5725","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"
```
