Commit 48610164 by luoqi

docs(push): 重整 Push 契约 — A(推原生行)为主/C(结构化事件)备选;subjectId 选填;emrId 移入 payload

- 两形态二选一,A 默认(字段最少、口径同数仓、可 reparse 自愈),C 备选(强宿主)。
- 公共 envelope 收敛为真·通用字段(subjectType/action/tenantId/patientId/occurredAt/updatedAt);
  subjectId 选填(有则更准、无则 PAC 用自然键合成),clinicId 条件,emrId 归 payload(资源专属)。
- 幂等:PAC 合成 source_event_id(宿主不发),partial UNIQUE + 内容指纹双层保证,A 即数仓同款已实证。
- 软删走 *_cancelled + updatedAt。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent c613d0e1
# PAC Push 接入(宿主推送)对接文档 # PAC Push 接入(宿主推送)对接文档
> **给宿主开发**:把患者事实**实时推**给 PAC。一个端点、HMAC 验签、JSON 批量。 > **给宿主开发**:把患者事实**实时推**给 PAC。你只发业务系统里**本来就有的字段**;
> 你只发"业务系统里本来就有的字段";K 码映射 / 幂等键 / ID 合成 / 时区归一**都 PAC 内部做**,你不用管 > 标准码映射 / 幂等键 / 内部 ID 合成 / 外键关联 / 时区归一 **全部 PAC 内部做**
> 配套:Pull(PAC 主动拉)见 `host-integration.md`;本文件只讲 Push > **必须 HTTPS**。配套 Pull(PAC 主动拉)见 `host-integration.md`
--- ---
## 0. TL;DR(最小闭环) ## 0. 两种接入形态(二选一)
``` | | **形态 A:推原生行(推荐)** | **形态 C:推结构化事件(备选)** |
POST https://<pac-host>/pac/v1/push/events |---|---|---|
Headers: X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature(HMAC) | 你发什么 | 你导出给数仓的那种**原生表行,照抄** | PAC 约定的事件(envelope + payload)|
Body: { "events": [ { …envelope…, "payload": { …资源字段… } } ] } // 批量,≤500 条/请求 | 你要懂 | 只懂**自己的数据字典** | 还要懂 PAC 的字段/类型约定 |
``` | 拆分 / 关联 / 码映射 | **全 PAC** | payload 的码仍 PAC 映射,其余你排好 |
- **必须 HTTPS**(HMAC 防伪造,不防偷看,患者数据要 TLS)。 | 一次性成本 | 给一份**数据字典**,PAC 写映射 yaml | 对一遍字段约定 |
- 每条 `event` = **公共字段(envelope)** + **payload(资源专属)** | 适合 | **绝大多数**(诊所 / HIS / 老系统)| 有数据中台、愿稳定吐标准格式的强宿主 |
- **幂等**:你只要保证 `subjectId`(或诊断这类的 `emrId`)稳定 + `updatedAt` 正确,重推/改/删 PAC 自动处理。
**默认选 A**:字段最少、口径与数仓一致;PAC 以后改映射字典能**自愈历史**(无需你重发)。
鉴权(§1)两形态通用;防重(§4)两形态都保证。
--- ---
## 1. 鉴权(HMAC-SHA256) ## 1. 鉴权(HMAC-SHA256,两形态通用)
| Header | 值 | | Header | 值 |
|---|---| |---|---|
| `X-PAC-Host-Id` | PAC 接入时分配给你的 host UUID(它也表明"你是谁")| | `X-PAC-Host-Id` | PAC 分配的 host UUID(也表明"你是谁")|
| `X-PAC-Timestamp` | 当前 Unix 秒(PAC 容忍 ±5 分钟,防重放)| | `X-PAC-Timestamp` | 当前 Unix 秒(容忍 ±5 分钟,防重放)|
| `X-PAC-Signature` | `hex( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) )` | | `X-PAC-Signature` | `hex( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) )` |
- `secret`:接入时 PAC 一次性安全下发,**双方各存一份原文**,永不上网(只传算出的签名) - `secret`:接入时 PAC 一次性安全下发,双方各存原文,永不上网
- **签名对象 = `时间戳 . 原始请求体字节`**;⚠️ **"用来签的 body" 与 "发出去的 body" 必须是同一串字节**(先 `JSON.stringify` 一次,签它、发它,中间别再序列化)。 - ⚠️ **"用来签的 body" 与 "发出去的 body" 必须是同一串字节**(先 `JSON.stringify` 一次,签它、发它)。
- **密钥轮换**:PAC 会给你新 secret,过渡期内新旧都接受;你切换完成后旧的作废。零停机 - 密钥轮换:PAC 给新 secret,过渡期新旧都收,你切完旧的作废(零停机)
签名示例(Node):
```js ```js
import { createHmac } from 'crypto'; const body = JSON.stringify(payload);
const body = JSON.stringify({ events }); const ts = Math.floor(Date.now()/1000);
const ts = Math.floor(Date.now() / 1000); const sig = require('crypto').createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex');
const sig = createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex'); // headers: X-PAC-Host-Id / X-PAC-Timestamp=ts / X-PAC-Signature=sig ; body 原样发
await fetch(`${PAC}/pac/v1/push/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-PAC-Host-Id': HOST_ID,
'X-PAC-Timestamp': String(ts),
'X-PAC-Signature': sig,
},
body, // ← 就是上面那串 body,别重新 stringify
});
``` ```
--- ---
## 2. 请求 / 响应 ## 2. 形态 A:推原生行(推荐)
**请求体**:
```jsonc
{ "events": [ /* 1~500 条;可混不同 subjectType;顺序无要求 */ ] }
```
**响应**(HTTP 200):
```jsonc ```jsonc
POST /pac/v1/push/rows
{ {
"syncLogId": "uuid", "tenantId": "ruier", // 硬隔离边界;多品牌必填,单租户可由 PAC 配置兜底
"accepted": 12, // 收到事件数 "source": "fact_emr_treatment_out", // 哪张源表(= 你给数据字典里的表名)
"transactionsWritten": 10, // 实际入库(去重后) "rows": [
"duplicates": 2, // 幂等命中跳过 { /* 你那张表的一行,原生字段照抄,含嵌套结构(如 diag 数组)都不用拆 */ }
"failed": 0 ]
} }
``` ```
鉴权失败 → 401;字段不合法 → 400(envelope 含错误明细)。
--- **对原生行的唯一要求**(都是你表里本来就有的列):
- 有患者引用列(如 `patient_id`);
-**末次修改时间**列(如 `updated_date`)——驱动幂等/版本;
- 有该行的自然键(如 `id`)。
## 3. 公共字段(envelope,所有资源通用) PAC 收到后(用你一次性提供的数据字典生成的 yaml):**拆分**(一行 EMR → 病历 + 多条诊断 + 治疗,外键自动挂)→ **诊断名映射标准码****合成幂等键去重** → 落库。**跟数仓那条管线一模一样,已在生产运行。**
| 字段 | 必填 | 说明 | > 你**不需要**发 `subjectId` / `emrId` / 标准码 / 幂等键——原生行里有什么 PAC 用什么,关系和 ID 它自己补。
|---|---|---|
| `subjectType` | ✅ | 资源类型:`diagnosis` / `treatment` / `appointment` / `payment` / `emr` / …(见 §4)|
| `action` | ✅ | 生命周期动作:`*_recorded`(记录)/ `*_created` / `*_changed` / `*_cancelled`(撤回,见 §6)|
| `tenantId` | ✅ | **硬隔离边界**(如品牌 `ruier` / `ruitai`)。单租户宿主可由 PAC 配置兜底,**多品牌必填**`clinicId` 不能替代它 |
| `patientId` | ✅ | 你系统里的患者号 |
| `clinicId` | ✅ | 发生诊所/机构号(业务字段)|
| `emrId` | 条件 | **关联电子病历的外键**(诊断 / 治疗 / 医生建议**必填**;预约 / 收费等无则不填)|
| `occurredAt` | ✅ | 业务发生时间,ISO8601 带时区。**没有独立"发生时间"字段就用记录创建时间** |
| `updatedAt` | ✅ | 记录末次修改时间。**内容变了就更新它;重试未变的记录保持不变**(幂等/版本依据)|
| `subjectId` | 条件 | **单条资源**(如预约)填该记录自己的 id;**一父多子资源**(如诊断,用 `payload.items[]`)**不填**,身份按 item 走(见 §4.1)|
> **你不需要发**:`source_event_id`(幂等键)/ K 码 / 内部主键——这些 PAC 用上面字段自己合成。
--- ---
## 4. payload(按 subjectType) ## 3. 形态 C:推结构化事件(备选)
### 4.1 diagnosis(诊断,完整示例) ```jsonc
POST /pac/v1/push/events
{ "events": [ /* 1~500 条;可混不同 subjectType;顺序无要求 */ ] }
```
一次病历常有多条诊断 → 放 `payload.items[]`,**每条 item 一条诊断**: 每条 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 ```jsonc
{ {
"events": [{ "subjectType":"diagnosis", "action":"diagnosis_recorded",
"subjectType": "diagnosis", "tenantId":"ruier", "patientId":"1328199", "clinicId":"7d49539c",
"action": "diagnosis_recorded", "occurredAt":"2025-12-06T13:51:44+08:00", "updatedAt":"2025-12-25T18:38:21+08:00",
"tenantId": "ruier", "payload": {
"patientId": "1328199", "emrId": "16bee63f...", // 关联电子病历(诊断/治疗/建议有;预约/收费无)
"clinicId": "7d49539c...", "doctorId":"5725", "doctorName":"叶谦",
"emrId": "16bee63f...", // 关联电子病历 "items": [
"occurredAt": "2025-12-06T13:51:44+08:00", { "name":"菌斑性龈炎", "tooth":"", "code":"" }, // name=原始诊断名(PAC 映射码);code 选填;tooth 选填
"updatedAt": "2025-12-25T18:38:21+08:00", { "name":"恒牙深龋", "tooth":"17;37", "code":"K02.800x008" }
"payload": { ]
"doctorId": "5725", }
"doctorName": "叶谦",
"items": [
{ "name": "菌斑性龈炎", "tooth": "", "code": "" },
{ "name": "恒牙深龋", "tooth": "17;37", "code": "K02.800x008" }
]
}
}]
} }
``` ```
item 字段: **其它资源 payload 速查**:
| 字段 | 必填 | 说明 | | subjectType | action 例 | payload 关键字段 |
|---|---|---| |---|---|---|
| `name` | ✅ | **原始诊断名(中文照填)**。PAC 自己映射到标准码,你**不用查 K 码** | | `emr`(病历本体)| `emr_recorded` | `doctorId/doctorName, illnessDesc, examFindings, disposal, doctorAdvice` |
| `tooth` | ⭕ | 牙位(FDI,多颗 `;` 分隔;全口留空)| | `treatment` | `treatment_recorded` | `emrId, items[]:{ category, subtype, tooth, status, startedAt, completedAt }` |
| `code` | ⭕ | 你系统自己的诊断码(如 ICD)。有则 PAC 优先用,无则按 `name` 映射 | | `recommendation` | `recommendation_recorded` | `emrId, items[]:{ name, tooth, windowDays }` |
| `id` | ⭕ | 该诊断自己的 id(有最好;没有 PAC 用 `emrId+name+tooth` 当身份)| | `appointment` | `appointment_created/changed/cancelled` | `scheduledAt, status, complaintCategory`(建议带 `subjectId`=预约 id)|
| `payment`/`recharge`/`refund` | `*_recorded` | `amountCents, currency`(建议带 `subjectId`)|
### 4.2 其它资源 payload 速查 | `consultation` | `consultation_recorded` | `intentCategories[]` |
| subjectType | action 例 | emrId | payload 关键字段 |
|---|---|---|---|
| `emr`(电子病历本体)| `emr_recorded` | 自身 | `doctorId/doctorName, illnessDesc, examFindings, disposal, doctorAdvice` |
| `treatment`(治疗)| `treatment_recorded` | ✅ | `items[]: { category, subtype, tooth, status, startedAt, completedAt }` |
| `recommendation`(医生建议)| `recommendation_recorded` | ✅ | `items[]: { name, tooth, windowDays }` |
| `appointment`(预约)| `appointment_created/changed/cancelled` | — | `subjectId`(预约 id)+ `scheduledAt, status, complaintCategory` |
| `payment`(收款)| `payment_recorded` | — | `subjectId` + `amountCents, currency` |
| `recharge`(储值)| `recharge_recorded` | — | `subjectId` + `amountCents, currency` |
| `refund`(退费)| `refund_recorded` | — | `subjectId` + `amountCents, currency` |
| `consultation`(咨询)| `consultation_recorded` | — | `subjectId` + `intentCategories[]` |
> 金额一律 **整数分**(`amountCents`)+ `currency`;时间一律 **ISO8601 带时区**。其余资源按需扩展,字段语义同上。
---
## 5. 幂等 / 去重(你只管两件事) > 金额一律**整数分**(`amountCents`)+ `currency`;时间一律 **ISO8601 带时区**。
PAC 用 **稳定身份 + `updatedAt`** 自动合成幂等键,你**不发** `source_event_id`。你只要保证: ---
1. **身份稳定**:`subjectId`(单条资源)或 `emrId`(诊断/治疗,配合 item 的 `name/tooth`)同一条记录永远一致; ## 4. 幂等 / 防重(两形态都保证,你只管两件事)
2. **`updatedAt` 正确**:内容变了它变,重试未变它不变。
由此: PAC **自己合成幂等键**(你**不发** `source_event_id`)。你只要:
1. **身份稳定**:有 `subjectId` 就给;没有,保证自然键稳定(诊断 = `emrId+name+tooth`);
2. **`updatedAt` 正确**:内容变它变、重试不变。
| 你的行为 | PAC 结果 | | 你的行为 | PAC 结果 |
|---|---| |---|---|
| 网络抖动**重推一模一样的** | **自动跳过**(幂等,不会重复)| | 网络抖动**重推一模一样的** | **自动跳过**(重复)|
| 改了内容**重推** | 自动**升版本**(旧版作废、新版生效)| | 改了内容**重推** | 自动**升版本**(旧版作废)|
| 同一请求里夹了重复条目 | 自动折叠 | | 同请求里夹重复条目 | 自动折叠 |
> 即使你失误重推完全相同的两条,PAC 也**保证不重复**(内容指纹双重兜底)。**你不需要自己保证事件 id 唯一**。 **保证机制**(已实现):`partial UNIQUE(host, tenant, source_event_id)` + fact 内容指纹**双层**——即使你失误重推完全相同两条,也**绝不产生重复****你无需自己保证事件 id 唯一。**(形态 A 即数仓同款管线,幂等已生产实证。)
--- ---
## 6. 撤回 / 删除(必须软删)⚠️ ## 5. 撤回 / 删除(必须软删)⚠️
PAC 靠你**发事件**才知道发生了什么——**物理删除 PAC 永远感知不到**。所以:
- **关键资源必须逻辑删除(软删)**,不要物理删; PAC 靠你**发事件**才知道——**物理删除永远感知不到**。所以:
- 撤回一条记录:**重发该记录,`action` 用 `*_cancelled`**(如 `diagnosis_cancelled` / `appointment_cancelled`)**+ 更新 `updatedAt`**; - **关键资源必须逻辑删(软删),别物理删**;
- 诊断这类 `items[]` 资源:撤回某条就发**该条**(同 `emrId + name + tooth`)的 `*_cancelled`; - 撤回:**重发该记录、`action=*_cancelled`(如 `diagnosis_cancelled`)+ 更新 `updatedAt`**;
- PAC 收到后把对应 fact 置为"已取消",不再参与召回。 - 诊断这类 `items[]`:撤回某条就发**该条**(同 `emrId+name+tooth`)的 `*_cancelled`;
- PAC 把对应记录置"已取消",不再参与召回。
(这与 Pull 通道的对接约束一致:关键资源需逻辑删除并更新 `updatedAt`。) (与 Pull 通道一致:关键资源需逻辑删除并更新 `updatedAt`。)
--- ---
## 7. 限制 / 约定 ## 6. 限制 / 约定
| 项 | 约定 | | 项 | 约定 |
|---|---| |---|---|
| 批量 | ≤ 500 events / 请求 | | 批量 | 形态 C ≤ 500 events;形态 A 按批推荐 ≤ 数百行/请求 |
| 金额 | 整数分 + `currency` | | 金额 / 时间 | 整数分 + currency;ISO8601 带时区(无时区拒收)|
| 时间 | ISO8601 带时区(无时区会被拒)|
| 编码 | UTF-8 | | 编码 | UTF-8 |
| 重试 | 失败可安全重试(幂等);建议指数退避 | | 重试 | 失败可安全重试(幂等);建议指数退避 |
| 实时性 | 准实时即可,无需毫秒级 |
--- ---
## 8. 宿主接入 Checklist ## 7. 宿主接入 Checklist
- [ ] 拿到 PAC 分配的 `Host-Id` + `secret` - [ ] 拿到 PAC 分配的 `Host-Id` + `secret`
- [ ] 实现 HMAC 签名(`{ts}.{rawBody}`,同一串 body 签+发) - [ ] 实现 HMAC 签名(`{ts}.{rawBody}`;同一串 body 签+发)
- [ ] 业务节点(写诊断/治疗/预约/收款…)触发推送,组装 envelope + payload - [ ] **选形态**:能直接吐原生行 → A(给数据字典);否则 → C(对字段约定)
- [ ] 保证每条记录 `subjectId`/`emrId` 稳定、`updatedAt` 随内容变化 - [ ] 每条记录:身份稳定(有 `subjectId` 给,无则自然键稳定)、`updatedAt` 随内容变
- [ ] 诊断/治疗/建议带 `emrId`;多条放 `items[]` - [ ] 多品牌每条带正确 `tenantId`
- [ ] 多品牌:每条带正确 `tenantId` - [ ] 删除走软删 + `*_cancelled`
- [ ] 删除走软删 + `*_cancelled` 事件
- [ ] 全程 HTTPS;失败重试 - [ ] 全程 HTTPS;失败重试
--- ---
## 附:一条完整请求(curl) ## 附:一条完整请求(形态 C,curl)
```bash ```bash
TS=$(date +%s) TS=$(date +%s)
BODY='{"events":[{"subjectType":"diagnosis","action":"diagnosis_recorded","tenantId":"ruier","patientId":"1328199","clinicId":"7d49539c","emrId":"16bee63f","occurredAt":"2025-12-06T13:51:44+08:00","updatedAt":"2025-12-25T18:38:21+08:00","payload":{"doctorId":"5725","doctorName":"叶谦","items":[{"name":"菌斑性龈炎","tooth":"","code":""},{"name":"恒牙深龋","tooth":"17;37","code":"K02.800x008"}]}}]}' 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}') SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "$PAC/pac/v1/push/events" \ curl -X POST "$PAC/pac/v1/push/events" -H "Content-Type: application/json" \
-H "Content-Type: application/json" \ -H "X-PAC-Host-Id: $HOST_ID" -H "X-PAC-Timestamp: $TS" -H "X-PAC-Signature: $SIG" --data "$BODY"
-H "X-PAC-Host-Id: $HOST_ID" -H "X-PAC-Timestamp: $TS" -H "X-PAC-Signature: $SIG" \
--data "$BODY"
``` ```
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment