# PAC 宿主对接契约 v0

> **版本**:v0(草案,待评审)
> **对象**:接入 PAC 的宿主开发团队(5i5ya / Friday / 400 / DW / 第三方)
> **状态**:🟡 草案,W2 末发出,W3-W4 联调收敛后发 v1.0
> **维护**:PAC 团队(luoqi);跨团队对接协调统一通过 CTO(于总)

---

## 目录

- [1. 概览](#1-概览)
- [2. 鉴权](#2-鉴权)
- [3. Pull 路径(PAC 主动拉资源)](#3-pull-路径pac-主动拉资源)
- [4. Push 路径(宿主主动推事件)](#4-push-路径宿主主动推事件)
- [5. PAC 开放 REST API](#5-pac-开放-rest-api)
- [6. 用户名解析接口(host → PAC 出站点查)](#6-用户名解析接口)
- [7. Field Mapping 配置](#7-field-mapping-配置)
- [8. Action URLs](#8-action-urls)
- [9. PAC Outbound Webhook(v1 不提供)](#9-pac-outbound-webhook)
- [10. 七层可靠性防线](#10-七层可靠性防线)
- [11. 接入流程 + Checklist](#11-接入流程--checklist)

---

# 1. 概览

## 1.1 PAC 是什么

**PAC(Patient Analyze Center)= 集团级"召回算法大脑"**。
接收宿主侧的患者事实数据,自动评价患者画像,生成召回任务清单 + AI 话术 + 一页摘要,
让客服每个电话打在刀刃上。

### PAC 做的

- 接收患者事实(治疗 / 预约 / 缴费 / 影像 / 病历元数据)
- 自动评价患者(画像 / 风险 / 价值)
- 生成召回名单 + 优先级 + 召回原因
- AI 生成参考话术 + 一页患者摘要
- 接收执行结果回写(客服打了之后填的结果)
- 对外输出集团级患者画像数据(供宿主多场景复用)

### PAC 不做的(留给宿主)

- 替代宿主已有业务系统(5i5ya / Friday)
- 控制企微 / 外呼 / SCRM 等触达通道
- 持有完整病历原文(EMR 在医生侧 / 临时借用通过 inputSnapshot)
- 主权影像 AI / 影像诊断(交给专业医疗 AI)
- 直连患者(所有动作通过宿主中转)

## 1.2 三种数据通道

```
┌─ PAC 接入 ────────────────────────────────────────────────┐
│                                                             │
│  Pull(PAC → host)  ──→  Host 资源 API                    │
│      用途:批量拉资源 / 单患者刷新                          │
│      触发:cron 周期 + 单患者按需                           │
│      数据形态:host 返回 canonical 资源对象                 │
│                                                             │
│  Push(host → PAC)  ──→  POST /pac/v1/push/events          │
│      用途:实时事件流(可选,宿主主动接入)                │
│      触发:host 业务事件发生                                │
│      数据形态:transaction-shaped(action + subject + ...)  │
│      安全:HMAC 签名验证                                    │
│                                                             │
│  开放 REST API     ──→  /pac/v1/{plans,patients,...}      │
│      用途:host 前端 / 后端调用 PAC 业务能力                │
│      调用方:PAC 前端 + host 自有前端 / 后端 都可          │
│      鉴权:Bearer JWT(换票流程)                          │
│                                                             │
└────────────────────────────────────────────────────────────┘

附加:host 反向提供
   ┌─ host POST /users:resolve(PAC 调 host)                │
   │   用途:userId → name 解析,token 字典覆盖不到时兜底     │
   └─────────────────────────────────────────────────────────┘
```

**接入策略**:
- **Pull 必接**(主流路径,host 提供资源 endpoint)
- **Push 可选**(宿主有事件总线 / 想实时推 → 接;否则 pull 周期覆盖)
- **PAC 开放 API 必接**(host 前端要展示召回 / Plan / 执行回写)
- **users:resolve 推荐接**(token 字典覆盖不到的 userId 兜底解析)

## 1.3 角色定义

PAC 视角下的概念,跟宿主既有概念对应:

| PAC 概念 | 含义 | 来源 |
|---|---|---|
| `platform` | 接入的宿主主体(friday / 5i5ya / 400 中心 / dw / mock) | PAC admin 注册 |
| `tenant` | 宿主侧的客户 / 集团 id | host token 传 |
| `clinic` | 宿主侧的诊所 id | host token 传 + 各事件携带 |
| `user` | 客服 / 主管 / 管理员 (PAC 框架固定 3 role) | host token 传 |

**PAC 不维护 `tenant` / `clinic` / `user` 主数据表** — 所有这些 id 都是 host 提供的字符串,PAC 信任 host 的命名空间。

**同一物理集团接两个宿主** → PAC 视角是 2 个独立 tenant(by-platform isolation),数据不串。

## 1.4 接入流程总览图

```mermaid
sequenceDiagram
  participant Host as 宿主(5i5ya/Friday/...)
  participant PAC

  Note over Host, PAC: ─── Phase 1: 注册接入 ───
  Host->>PAC: 申请 platform 注册<br/>(联系人 / 试点 clinic 清单)
  PAC->>PAC: admin 控制台创建 platform<br/>生成 appId + appSecret + pushSecret(可选)
  PAC-->>Host: appId + appSecret + pushSecret<br/>(一次性 plaintext)
  
  Note over Host, PAC: ─── Phase 2: 配置参数 ───
  Host->>PAC: 提供 pullConfig(endpoints + credentials + amountUnit + timezone)
  Host->>PAC: 提供 fieldMapping(host 字段 → PAC canonical)
  Host->>PAC: 提供 actionUrls(host UI 深链模板)
  
  Note over Host, PAC: ─── Phase 3: 联调 ───
  PAC->>Host: pull 测试(逐资源验证 endpoint + mapping)
  Host->>PAC: push 测试(可选,推几条 transaction)
  PAC-->>Host: 反馈数据形态问题
  
  Note over Host, PAC: ─── Phase 4: 灰度 ───
  Host->>PAC: 1 家试点门诊数据冷启
  PAC->>PAC: 跑通 Persona / Plan 流水线
  PAC-->>Host: 在 host UI / PAC iframe 看召回任务
  
  Note over Host, PAC: ─── Phase 5: 真实接入 ───
  Host->>PAC: 全部试点门诊接入
  PAC->>Host: 每日 cron 拉 + push 实时(若启用)
```

**典型时长**:Phase 1-3 约 1-2 周;Phase 4-5 约 2-4 周。具体看宿主侧 API 就绪度。

---

# 2. 鉴权

## 2.1 三种凭证

| 凭证 | 谁持有 | 用途 |
|---|---|---|
| `appId` + `appSecret` | host backend | 调 PAC `/auth/token` 换 JWT |
| `pushSecretHashes[]`(可选) | host backend(plaintext)+ PAC(hash) | host 推 event 时 HMAC 签名 |
| `pullConfig.credentials` | host 自定义 | PAC 调 host 资源 API 用(host 颁发) |

**安全规则**:
- `appSecret` / `pushSecret`(plaintext)**一次性返回**,host 必须立即存好,PAC 永不回读
- 失落 → 走 PAC admin 控制台**轮换**(生成新的,push 走数组 grace 期共存)
- 所有 secret 在 PAC 侧 scrypt 哈希落库,不存 plaintext

## 2.2 换票流程(host backend → PAC)

```
host 侧用户登录(host 自己的登录态)
   ↓ host backend 知道 userId / role / clinicIds / tenant
host backend → POST /pac/v1/auth/token
   body: {
     "appId":     "pac_friday_a1b2c3d4",
     "appSecret": "<host 持有的 plaintext>",
     "user": {
       "userId":    "host-side-user-id",
       "tenantId":  "host-side-tenant-id",
       "role":      "staff" | "leader" | "admin",
       "clinicIds": ["clinic_hz_dasha", "clinic_hz_gaode"]
     }
   }
   ↓
PAC verifies appSecret + signs JWT
   response: {
     "accessToken":     "<JWT bearer, 2h TTL>",
     "refreshToken":    "<refresh JWT>",
     "code":            "<one-time code, 60s TTL>",
     "expiresIn":       7200,
     "codeExpiresIn":   60
   }
   ↓
host backend 把 code 拼到 PAC iframe URL:
   https://pac.example.com/embed?code=XXX
   ↓
PAC frontend → POST /pac/v1/auth/exchange-code  { code }
   → 拿到 accessToken,后续 API 调用 Authorization: Bearer <accessToken>
```

### 关键约定

| 项 | 规则 |
|---|---|
| `role` 取值 | **必须**是 `staff` / `leader` / `admin` 之一(PAC 框架固定 3 个) |
| `clinicIds` | 用户授权可访问的所有 clinic(staff 通常 1 个,leader 多个) |
| `tenantId` | host 自定义命名空间,**接入后不能变更**(数据隔离的基础) |
| 错误码 | `40101 AUTH_INVALID_CREDENTIALS`(appId/Secret 错)/ `40103 AUTH_INVALID_CODE`(code 过期)/ `40104 AUTH_INVALID_REFRESH_TOKEN` |

## 2.3 Token 字典(host 推回的 user / clinic 名字)

PAC 不存 host 的 user 主数据,**展示需要 name 时**靠 host 在换票时附带字典:

```json
TokenExchangeRequest.user = {
  "userId":    "u_001",
  "tenantId":  "t_xxx",
  "role":      "staff",
  "clinicIds": ["c_1", "c_2"],
  "dictionary": {                              ⭐ 可选但强烈推荐
    "userName": "张三",
    "clinics":  [
      { "id": "c_1", "name": "杭州大厦门诊" },
      { "id": "c_2", "name": "杭州高德门诊" }
    ],
    "team":     [                              // 仅 leader 必带
      { "id": "u_002", "name": "李四", "role": "staff" },
      { "id": "u_003", "name": "王五", "role": "staff" }
    ]
  }
}
```

**字典生命周期**:跟 access token 同寿命(2h);PAC 前端 cache 用。
**覆盖不到的 userId**(如离职 / 历史 plan 老 assignee):PAC 后端调 host `/users:resolve` 兜底(见 §6)。

## 2.4 错误码速查

| code | HTTP | 含义 |
|---|---|---|
| `40100` | 401 | 缺少 / 无效 token |
| `40101` | 401 | appId/Secret 错(PAC platform 不存在或停用) |
| `40103` | 401 | exchange code 过期 / 已用 |
| `40104` | 401 | refresh token 失效 |
| `40300` | 403 | 权限不足(role 没有该 permission) |
| `40301` | 403 | clinic 越界(请求 clinic 不在 token 授权范围) |

---

# 3. Pull 路径(PAC 主动拉资源)

## 3.1 工作机制

PAC 周期性 cron(默认每 5 分钟)+ 单患者按需,调 host 资源 endpoint 拉**当前态对象列表**;
adapter 比对前后态差异 → **合成 transaction**(action + subject + raw_payload)→ parser 衍生 fact。

### 三个 SLA

| 类型 | 频率 | 用途 |
|---|---|---|
| **周期 pull** | 默认 5 分钟 / 资源(`pullConfig.intervalMinutes`) | 批量增量同步 |
| **单患者即时 pull** | 客服打开患者页时按需调 | 实时刷新单条 |
| **对账 reconcile** | 每日 1 次(凌晨低峰) | 数据完整性兜底 |

### 接入抓手:`pullConfig`

```jsonc
{
  "endpoints": {
    // 患者主档
    "patients": "https://host.example.com/api/patients",
    // 11 种 subject_type 资源(按需配置,host 能提供哪些就配哪些)
    "consultations":       "...",
    "appointments":        "...",
    "visit_registrations": "...",
    "visit_receptions":    "...",
    "clinical_encounters": "...",
    "emr_documents":       "...",
    "charge_orders":       "...",
    "payments":            "...",
    "refunds":             "...",
    "image_studies":       "...",
    // 用户名解析点查(host → PAC 出站,见 §6)
    "users":               "https://host.example.com/api/users:resolve"
  },
  "credentials": {                  // 二选一
    "loginUrl": "https://host.example.com/api/auth/login",
    "username": "pac-bot",
    "password": "<encrypted>",
    // 或
    "apiKey":   "<bearer-token>"
  },
  "amountUnit":      "fen" | "yuan", // 必填,无默认
  "timezone":        "Asia/Shanghai", // 必填,IANA 时区
  "intervalMinutes": 5
}
```

## 3.2 Endpoint 列表 + 用途

| Endpoint key | 资源 | 推荐响应频率 | 必填? |
|---|---|---|---|
| `patients` | 患者主档 | 增量 | **必** |
| `consultations` | 400 / 接待中心咨询记录 | 增量 | 推荐 |
| `appointments` | 预约 | 增量 | **必** |
| `visit_registrations` | 挂号 | 增量 | 推荐 |
| `visit_receptions` | 接诊登记 | 增量 | 可选 |
| `clinical_encounters` | 医生接诊 | 增量 | **必** |
| `emr_documents` | 电子病历 | 增量 | 推荐 |
| `charge_orders` | 收费单 | 增量 | **必** |
| `payments` | 收款 | 增量 | **必** |
| `refunds` | 退费 | 增量 | 推荐 |
| `image_studies` | 影像资料 | 增量(metadata 即可,无需文件) | 可选 |
| `users` | 用户名解析 | 点查 | 推荐 |

**必填**(`patients` / `appointments` / `clinical_encounters` / `charge_orders` / `payments`):召回算法核心依赖,缺则 PAC 价值大打折扣。
**推荐**:增强画像精度。
**可选**:特定召回场景才需要。

## 3.3 通用响应契约

每个 endpoint 响应必须遵循以下信封:

```jsonc
{
  "items": [ ...资源对象数组... ],
  "nextCursor": "string | null",   // 增量游标,null=没有更多
  "hasMore":    true               // 跟 nextCursor 等价,任选其一
}
```

**资源对象 JSON shape** 见 [3.4](#34-canonical-资源对象-schema) 详解。

**字段命名规则**:host 用什么字段名都行,**通过 `fieldMapping` 翻译到 PAC canonical**(见 §7)。无 fieldMapping 时假设 host 字段名 = PAC canonical。

## 3.4 Canonical 资源对象 schema

PAC 期望(adapter 内部经过 fieldMapping 翻译后)看到的资源形态。**每种资源给一个完整示例**。

### 3.4.1 `patients`

```jsonc
{
  "externalId":  "P00012345",      // host 侧患者 id,跨同步稳定
  "name":        "张三",
  "phone":       "138xxxx0001",
  "gender":      "M" | "F" | string,   // 自由文本,PAC 不强枚举
  "birthDate":   "1985-03-20",      // 日期,YYYY-MM-DD
  "preferences": {                  // 自由 JSON,可空
    "contactWindow":    { "start": "10:00", "end": "12:00" },
    "language":         "zh-CN",
    "preferredChannel": "phone"
  },
  "doNotContact":       false,      // 合规,host 维护
  "doNotContactReason": null,
  "deceased":           false,
  "deceasedAt":         null,
  "status":             "active" | "archived" | "merged",
  "mergedIntoExternalId": null,     // 若 merged,指向新主档的 externalId
  "createdAt":          "2024-01-15T10:00:00+08:00",
  "updatedAt":          "2026-05-10T14:30:00+08:00"
}
```

**关键说明**:
- `externalId` **跨同步稳定**,host 侧主键
- `doNotContact=true` → 该患者永久从 PAC 召回池排除(合规硬要求)
- `status=merged` + `mergedIntoExternalId` → PAC 自动合并历史数据(见 §10)

### 3.4.2 `appointments`

```jsonc
{
  "externalId":      "apt_001",              // host 侧预约 id
  "patientExternalId": "P00012345",          // 必填,关联患者
  "clinicId":        "clinic_hz_dasha",
  "scheduledAt":     "2026-05-20T10:00:00+08:00",
  "appointmentType": "复查" | "初诊" | string,
  "doctorId":        "doc_zhang",
  "status":          "scheduled" | "arrived" | "cancelled" | "no_show",
  "createdAt":       "...",
  "updatedAt":       "..."
}
```

### 3.4.3 `clinical_encounters`

```jsonc
{
  "externalId":        "enc_001",
  "patientExternalId": "P00012345",
  "clinicId":          "clinic_hz_dasha",
  "doctorId":          "doc_zhang",
  "startedAt":         "2026-05-22T15:05:00+08:00",
  "endedAt":           "2026-05-22T15:35:00+08:00",
  "diagnoses": [                              // 数组,每次接诊可多个诊断
    {
      "code":     "K04.0",
      "name":     "不可复性牙髓炎",
      "toothPosition": "11"
    }
  ],
  "treatments": [                              // 数组,该次接诊做了哪些治疗
    {
      "category":       "endodontic",         // ⭐ 关键 — 召回算法识别用
      "subtype":        "root_canal",
      "toothPosition":  "11",
      "status":         "in_progress" | "completed",
      "expectedNextStep": "crown_restoration", // ⭐ 治疗链下一步,召回核心
      "expectedNextStepWindowDays": 30
    }
  ],
  "notes": "string"                            // 医生备注
}
```

**关键 fields**(召回算法消费):
- `treatments[].category`:`endodontic` / `orthodontic` / `implant` / `periodontic` / `restorative` / `consultation` / `cleaning`(枚举见 PAC 端 zod)
- `treatments[].status`:`completed` 是否已完成
- `treatments[].expectedNextStep`:下一步建议(为治疗链断点识别提供锚点)

### 3.4.4 `charge_orders` / `payments` / `refunds`

```jsonc
// charge_orders
{
  "externalId":      "co_001",
  "patientExternalId": "P00012345",
  "clinicId":        "clinic_hz_dasha",
  "createdAt":       "2026-05-22T16:00:00+08:00",
  "items": [
    {
      "name":           "根管治疗",
      "category":       "treatment",         // treatment / consultation / membership / other
      "treatmentCategory": "endodontic",     // 关联 encounter.treatments[].category
      "amount":         3200,                // 数字,单位由 amountUnit 决定
      "currency":       "CNY"
    }
  ],
  "totalAmount":     3200,
  "status":          "submitted" | "paid" | "partial_paid" | "cancelled"
}

// payments
{
  "externalId":      "pay_001",
  "patientExternalId": "P00012345",
  "chargeOrderExternalId": "co_001",   // 关联收费单
  "clinicId":        "clinic_hz_dasha",
  "paidAt":          "2026-05-22T16:05:00+08:00",
  "amount":          3200,
  "currency":        "CNY",
  "method":          "wechat_pay" | "cash" | string
}

// refunds
{
  "externalId":      "refund_001",
  "patientExternalId": "P00012345",
  "paymentExternalId": "pay_001",       // 关联收款
  "clinicId":        "clinic_hz_dasha",
  "refundedAt":      "...",
  "amount":          1500,
  "reason":          "string"
}
```

**金额单位** — `pullConfig.amountUnit` 决定:
- `"fen"`(分整数) → host 返回 `3200` = ¥32(适合精确金额)
- `"yuan"`(元小数) → host 返回 `3200` = ¥3200(adapter 自动 × 100 转分)

### 3.4.5 `emr_documents` / `image_studies` / 其他

(详见附件 OpenAPI JSON 完整 schema;此处省略)

## 3.5 增量游标契约

PAC 对每个资源维护一个**水位**(`sync_logs.cursorBefore` / `cursorAfter`),host 必须支持:

```
GET <endpoint>?after=<cursor>&limit=200
   ↓
Host 返回 updatedAt > cursor 的资源(按 updatedAt 升序),最多 limit 条 + nextCursor
   ↓
PAC 消化后用 nextCursor 继续拉,直到 nextCursor=null
```

**Cursor 形态规范**:
- 可以是时间戳(ISO 8601,如 `2026-05-22T16:00:00.000Z`)
- 或 host 自定义 token(opaque string,PAC 不解析)
- 必须**单调递增**,确保不会跳过资源
- **冷启**:首次 pull `cursor=null`(从最早开始)

**首次冷启的特殊处理**:
- Host **可以**支持 `?since=<ISO-8601>` 限定时间范围(避免拉全部历史)
- 推荐**冷启只拉最近 24 个月**的数据,更早数据按需 backfill

## 3.6 单患者即时 pull

```
PAC → GET <endpoint>?patientExternalId=P00012345
```

- **不推进 cursor**(避免污染批量水位)
- 客服打开患者页 30 秒内的多次调用 → PAC 内部 dedupe(只发一次 host 请求)
- 60 秒冷却期(避免短时间反复刷)

Host 返回**该患者所有相关资源的当前态**(不限增量,做"全量当前态拉取")。

## 3.7 错误处理

| Host 返回 | PAC 行为 |
|---|---|
| 200 + items | 正常消化 |
| 200 + 空 items + nextCursor=null | 当前 cursor 已是最新,跳过下次 |
| 401 / 403 | 触发凭证轮换告警,该资源暂停 pull |
| 404 (单患者 pull) | 该患者标 archived,记录 sync_log |
| 429 | 退避后重试(指数退避 + jitter) |
| 5xx | 重试 3 次,失败转 partial,下次继续 |

---

# 4. Push 路径(宿主主动推事件)

## 4.1 选择:Pull 还是 Push?

| 维度 | Pull | Push |
|---|---|---|
| 实时性 | ⚠️ 5 分钟延迟 | ✅ 秒级 |
| Host 改造代价 | ⚠️ host 要支持增量 cursor + endpoints | ✅ host 只要发事件 |
| 实施复杂度 | ✅ PAC 主动拉,host 被动 | ⚠️ host 主动推 + HMAC + 重试 |
| 适合场景 | host 没事件总线 / 历史系统 | host 有现代事件架构 |

**推荐**:**Pull 兜底,Push 增强**。Pull 必接,Push 可选;两者同时启用时 PAC 自动 dedupe(靠 `source_event_id` 幂等)。

## 4.2 Push endpoint

```
POST https://pac.example.com/pac/v1/push/events

Headers:
  Content-Type: application/json
  X-Pac-Platform-Id: <platform UUID,PAC 注册时给>
  X-Pac-Signature:  <HMAC-SHA256 of body>
  X-Pac-Timestamp:  <Unix ms>

Body:
  see §4.3
```

## 4.3 报文形状

```jsonc
{
  "events": [
    {
      "sourceEventId":   "host-evt-88123",     // ⭐ 必填,host 原生事件 ID,幂等键
      "sourceActorId":   "doc_zhang",          // 可选,操作人(host user id)
      "sourceActorRole": "doctor",             // 可选
      "clinicId":        "clinic_hz_dasha",    // ⭐ 必填,操作发生诊所
      "patientExternalId": "P00012345",        // 关联患者(无 patient 主体的管理事件可省)
      "action":          "appointment_created", // ⭐ 必填,见 §4.4 14 个枚举
      "subjectType":     "appointment",        // ⭐ 必填,见 §4.4 10 个枚举
      "subjectId":       "apt_001",            // ⭐ 必填,host 侧业务对象 id
      "rawPayload":      { ...host 原文... },   // ⭐ 必填,host 原生 payload(用于 audit / 重放)
      "occurredAt":      "2026-05-10T14:30:00+08:00" // ⭐ 必填,ISO 8601 带时区
    }
    // ... 单次最多 500 个事件
  ]
}
```

## 4.4 14 个 `action` × 10 个 `subjectType`

**起步范围**(应用层 zod 校验,schema 层 TEXT 不强枚举,允许将来扩展):

| `action`(14 个) | 对应 `subjectType` | 典型业务时机 |
|---|---|---|
| `consultation_created` | `consultation` | 400 中心 / 前台接待录入咨询 |
| `appointment_created` | `appointment` | 预约创建 |
| `appointment_changed` | `appointment` | 预约改时间 / 改医生 |
| `appointment_cancelled` | `appointment` | 预约取消 |
| `patient_arrived` | `visit_reception` | 患者到店签到 |
| `visit_registered` | `visit_registration` | 挂号成功 |
| `patient_received` | `visit_reception` | 前台接诊 |
| `doctor_encounter_started` | `clinical_encounter` | 医生开始接诊 |
| `charge_order_submitted` | `charge_order` | 收费单提交 |
| `payment_received` | `payment` | 收款确认 |
| `refund_created` | `refund` | 退费 |
| `emr_submitted` | `emr_document` | 病历提交 |
| `emr_rejected` | `emr_document` | 病历打回 / 质控不通过 |
| `image_uploaded` | `image_study` | 影像上传 |

**注**:
- `action` 越细越好(贴近 host 真实业务动作)
- 同一 `subjectType` 可能对应多个 `action`(如 appointment 的 created / changed / cancelled)
- W2+ 视宿主接入情况扩展(如加 `treatment_completed` 等细粒度 action)

## 4.5 HMAC 签名规范

PAC 用 `pushSecretHashes` 数组验签 — 支持平滑密钥轮换。

### Host 签名步骤

```
1. signing_string = X-Pac-Timestamp + "\n" + sha256(body)
2. signature      = base64( HMAC_SHA256(pushSecret_plaintext, signing_string) )
3. Header X-Pac-Signature = signature
```

### PAC 验签步骤

```
1. 检查 X-Pac-Timestamp 跟当前时间差 < 5 分钟(防 replay)
2. 重算 expected_signature
3. 逐个对比 platform.pushSecretHashes 数组中所有 key,任一命中即通过
4. 否则 401 反签
```

### 密钥轮换流程

```
当前:platform.pushSecretHashes = [hash_v1]
   ↓ host 想轮换
PAC admin → 调 /admin/platforms/{id}/rotate-push-secret
            返回新 plaintext pushSecret_v2
PAC 落库:platform.pushSecretHashes = [hash_v2, hash_v1]   // v2 在前 = current
   ↓ host 收到 v2,逐步把生产流量切到 v2 签名
   ↓ 完全切完后(grace period 24-48h)
PAC admin → 调 /admin/platforms/{id}/revoke-old-push-secrets
PAC 落库:platform.pushSecretHashes = [hash_v2]            // v1 彻底失效
```

**Host 侧最佳实践**:同时持有 v1 / v2 两个 secret,逐步切流(灰度),零停机切换。

## 4.6 错误响应

| HTTP | code | 含义 | Host 行为 |
|---|---|---|---|
| 200 | `0` | 全部 event 受理 | OK |
| 207 | `0` | 部分受理(items 数组返回每个事件结果) | 失败的事件按 errors[] 重发 |
| 400 | `40002` | 报文结构错(zod 校验失败) | 检查 schema |
| 401 | `40110` | HMAC 签名失败 | 检查 secret / 时间戳 |
| 401 | `40111` | 时间戳过期(>5 min) | 同步时钟 |
| 409 | `40901` | `source_event_id` 重复 | 幂等命中,Host 当成功(已入库) |
| 429 | `42901` | 限流 | 退避重试 |
| 5xx | — | PAC 内部错 | 退避重试 |

**幂等保证**:同一 `source_event_id` 重复提交永远返回 409 + `code=40901`,host 应该当成功处理。

## 4.7 单次最大 batch

- 单次 POST 最多 **500 events**(性能 / 内存平衡)
- 推荐 batch 大小:**50-100**(平衡延迟 / 吞吐)
- 超过 500 → 400 `40003 BATCH_TOO_LARGE`

---

> **回合 1 结束,第 5-11 章待续**(下次输出)
