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

> **给宿主开发**:把患者事实**实时推**给 PAC。一个端点、HMAC 验签、JSON 批量。
> 你只发"业务系统里本来就有的字段";K 码映射 / 幂等键 / ID 合成 / 时区归一**都 PAC 内部做**,你不用管。
> 配套:Pull(PAC 主动拉)见 `host-integration.md`;本文件只讲 Push。

---

## 0. TL;DR(最小闭环)

```
POST https://<pac-host>/pac/v1/push/events
Headers: X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature(HMAC)
Body:    { "events": [ { …envelope…, "payload": { …资源字段… } } ] }   // 批量,≤500 条/请求
```
- **必须 HTTPS**(HMAC 防伪造,不防偷看,患者数据要 TLS)。
- 每条 `event` = **公共字段(envelope)** + **payload(资源专属)**。
- **幂等**:你只要保证 `subjectId`(或诊断这类的 `emrId`)稳定 + `updatedAt` 正确,重推/改/删 PAC 自动处理。

---

## 1. 鉴权(HMAC-SHA256)

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

- `secret`:接入时 PAC 一次性安全下发,**双方各存一份原文**,永不上网(只传算出的签名)。
- **签名对象 = `时间戳 . 原始请求体字节`**;⚠️ **"用来签的 body" 与 "发出去的 body" 必须是同一串字节**(先 `JSON.stringify` 一次,签它、发它,中间别再序列化)。
- **密钥轮换**:PAC 会给你新 secret,过渡期内新旧都接受;你切换完成后旧的作废。零停机。

签名示例(Node):
```js
import { createHmac } from 'crypto';
const body = JSON.stringify({ events });
const ts   = Math.floor(Date.now() / 1000);
const sig  = createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex');
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. 请求 / 响应

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

**响应**(HTTP 200):
```jsonc
{
  "syncLogId": "uuid",
  "accepted": 12,            // 收到事件数
  "transactionsWritten": 10, // 实际入库(去重后)
  "duplicates": 2,           // 幂等命中跳过
  "failed": 0
}
```
鉴权失败 → 401;字段不合法 → 400(envelope 含错误明细)。

---

## 3. 公共字段(envelope,所有资源通用)

| 字段 | 必填 | 说明 |
|---|---|---|
| `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)

### 4.1 diagnosis(诊断,完整示例)

一次病历常有多条诊断 → 放 `payload.items[]`,**每条 item 一条诊断**:

```jsonc
{
  "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" }
      ]
    }
  }]
}
```

item 字段:
| 字段 | 必填 | 说明 |
|---|---|---|
| `name` | ✅ | **原始诊断名(中文照填)**。PAC 自己映射到标准码,你**不用查 K 码** |
| `tooth` | ⭕ | 牙位(FDI,多颗 `;` 分隔;全口留空)|
| `code` | ⭕ | 你系统自己的诊断码(如 ICD)。有则 PAC 优先用,无则按 `name` 映射 |
| `id` | ⭕ | 该诊断自己的 id(有最好;没有 PAC 用 `emrId+name+tooth` 当身份)|

### 4.2 其它资源 payload 速查

| 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. 幂等 / 去重(你只管两件事)

PAC 用 **稳定身份 + `updatedAt`** 自动合成幂等键,你**不发** `source_event_id`。你只要保证:

1. **身份稳定**:`subjectId`(单条资源)或 `emrId`(诊断/治疗,配合 item 的 `name/tooth`)同一条记录永远一致;
2. **`updatedAt` 正确**:内容变了它变,重试未变它不变。

由此:

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

> 即使你失误重推完全相同的两条,PAC 也**保证不重复**(内容指纹双重兜底)。**你不需要自己保证事件 id 唯一**。

---

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

PAC 靠你**发事件**才知道发生了什么——**物理删除 PAC 永远感知不到**。所以:

- **关键资源必须逻辑删除(软删)**,不要物理删;
- 撤回一条记录:**重发该记录,`action` 用 `*_cancelled`**(如 `diagnosis_cancelled` / `appointment_cancelled`)**+ 更新 `updatedAt`**;
- 诊断这类 `items[]` 资源:撤回某条就发**该条**(同 `emrId + name + tooth`)的 `*_cancelled`;
- PAC 收到后把对应 fact 置为"已取消",不再参与召回。

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

---

## 7. 限制 / 约定

| 项 | 约定 |
|---|---|
| 批量 | ≤ 500 events / 请求 |
| 金额 | 整数分 + `currency` |
| 时间 | ISO8601 带时区(无时区会被拒)|
| 编码 | UTF-8 |
| 重试 | 失败可安全重试(幂等);建议指数退避 |
| 实时性 | 准实时即可,无需毫秒级 |

---

## 8. 宿主接入 Checklist

- [ ] 拿到 PAC 分配的 `Host-Id` + `secret`
- [ ] 实现 HMAC 签名(`{ts}.{rawBody}`,同一串 body 签+发)
- [ ] 业务节点(写诊断/治疗/预约/收款…)触发推送,组装 envelope + payload
- [ ] 保证每条记录 `subjectId`/`emrId` 稳定、`updatedAt` 随内容变化
- [ ] 诊断/治疗/建议带 `emrId`;多条放 `items[]`
- [ ] 多品牌:每条带正确 `tenantId`
- [ ] 删除走软删 + `*_cancelled` 事件
- [ ] 全程 HTTPS;失败重试

---

## 附:一条完整请求(curl)

```bash
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"}]}}]}'
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"
```
