---
title: 接入 Runbook
description: 接入新 Host 时 PAC 团队侧需要执行的全部操作步骤,含 host 注册、cold-import 联调与运维场景。
icon: Server
---

> **场景**:有新宿主要接入 PAC,本文档列 PAC 团队侧需要做的全部操作步骤。
> **配套文档**:
> - 给 host 看的接入契约 → [接入总览](/docs/integration/overview)
> - 冷数据装载 / 标准化管道 → [数据摄入](/docs/architecture/data-ingestion)
> - 数据三层结构 → [三层数据结构](/docs/architecture/three-layer-model)

---

## 0. 当前 PAC 侧能力速查(2026-06-30)

| 能力 | 状态 | 接口 / 工具 |
|---|---|---|
| **Auth 换 JWT** | ✅ | `POST /pac/v1/auth/token` |
| **Host 管理(create/rotate/deactivate)** | ✅ | `pnpm pac:host <cmd>` CLI(走 SSH,不走 HTTP)|
| **Host 自助 admin(读 + 受限更新 + 轮换)** | ✅ | `GET / PATCH /pac/v1/admin/host/self` + `POST .../rotate-app-secret`、`.../push-secrets` |
| **AssemblerEngine — yaml 驱动 host→canonical 投影** | ✅ | `data/<host>/assemblers/*.yaml` + `AssemblerEngine` |
| **Sync 装载脱敏数据(首跑全量/日级增量统一)** | ✅ | `pnpm sync -- --dir=...`(cold-import = legacy 全量 alias)|
| **Patient timeline 可查** | ✅ | `GET /pac/v1/patients/:id/timeline` + `pnpm timeline -- --pid=...` |
| **Drift 单条识别** | ✅ | `ContractDriftError` + `[drift]` 日志前缀 |
| **数据建模能力** | ✅ | **23 action / 16 subjectType / 16 fact_type / 33 personaFeatureKey / 14 outcome**(枚举见 [事件枚举](/docs/event-enums))|
| **Push 接收 + HMAC 验签** | ✅ | `POST /pac/v1/push/events` + `/push/rows`(两形态)+ HMAC 验签 |
| **Persona / Plan engine** | ✅ | persona features(规则 extractor)+ plan engine / scenarios |
| **批量 Pull cron / manual** | ❌ STUB | `SyncService.runPull` 抛 `NotImplementedException`;**单患者刷新已实装**(`runPullForPatient`)|
| **Reconcile 对账 / Quarantine 表** | ⚠️ 部分 | `ReconcileOrchestrator` 已建,依赖真 pull adapter(目前仅 `MockPullStrategy`)才生效 |

---

## 1. 接入流程(PAC 团队步骤)

> 对应 [接入总览](/docs/integration/overview) §6 —— host 视角看到的同一条流程。

### Phase 1:Host 注册(走 CLI,**不走 HTTP**)

敏感操作(create / rotate / deactivate)**只走 CLI**,SSH 到 PAC 服务器 + DB 直连。
理由:host 注册一年几次,低频高重要;web 鉴权 + PAC 内部用户系统是 over-engineering。

```bash
# SSH 到 PAC 生产/staging 服务器
cd /apps/pac-service

# 注册新 host
pnpm pac:host create --name=friday --push --amount-unit=yuan --timezone=Asia/Shanghai

# 输出(plaintext 密钥仅 stdout 一次性显示)
# ✓ Created host "friday"
#   id:        a1b2c3d4-...
#   appId:     pac_friday_x7k9m2lp
#   appSecret: <plaintext, 仅显示一次>
#   pushSecret: <plaintext, 仅显示一次>
#   active:    true
#
# ⚠️  立即把 appSecret / pushSecret 存到 1Password 共享空间 "PAC-Production/friday",
#    PAC 不再保留 plaintext,失落只能 rotate。
```

**CLI 命令清单**:

```bash
pnpm pac:host list                       # 列出所有 host
pnpm pac:host get <id-or-name>           # 单个详情
pnpm pac:host create --name=<slug> ...   # 注册新 host
pnpm pac:host rotate-secret <id-or-name> # 轮换 appSecret(旧立即失效)
pnpm pac:host rotate-push <id-or-name>   # 轮换 pushSecret(数组前插,grace)
pnpm pac:host deactivate <id-or-name>    # 软停(auth 立即拒绝)
```

**安全模型**:

| 控制项 | 实现 |
|---|---|
| 谁能跑 CLI | SSH 到 PAC 服务器(SSH key 限名单)|
| DB 凭证保护 | `.env` 里 `DATABASE_URL` 只生产节点可读 |
| 操作审计 | SSH 登录日志 + DB binlog;后续可加 `pac_admin_actions` 表 |
| Plaintext 密钥 | CLI 输出 stdout,运维即看即存 1Password,不入日志 |

**HTTP `/admin/host/self`** —— host **自助**查看 / 受限更新自己(不是 admin 管所有 host):
- `GET /admin/host/self` 自身信息 · `GET /admin/host/self/stats` 统计
- `PATCH /admin/host/self` 改 actionUrls / pullConfig 等(不改密钥)
- `POST /admin/host/self/rotate-app-secret`、`POST .../push-secrets`、`DELETE .../push-secrets/:suffix` —— host 自助轮换密钥

**create / deactivate 仍只走 CLI**(`pnpm pac:host`,SSH)。

### Phase 2:发凭据给 host(线下 / 安全通道)

把 `appId` + `appSecret`(+ `pushSecret` 如果启用 push)通过加密邮件 / 1Password 等发给 host backend 团队。

Host 拿到后即可:

```bash
# host 验证凭据可用
curl -X POST http://pac.example.com/pac/v1/auth/token \
  -H 'Content-Type: application/json' \
  -d '{
    "appId":"pac_friday_a1b2c3d4",
    "appSecret":"IYPRA...",
    "user":{"userId":"u_kefu_001","tenantId":"t_friday_grp1","role":"staff","orgScope":["clinic_a"]}
  }'
# → 拿到 host 侧用户的 JWT
```

### Phase 3:host 给裸表 + 数据字典 → PAC 写 assembler.yaml ⭐

**这是 v2 的关键步骤**(替代 v1 的"录 fieldMapping")。host 提供裸表 + 数据字典的契约见 [数据请求清单](/docs/integration/data-request-checklist)。

3.1 Host 给 PAC 一个**目录**:

```text
friday-export-20260513.zip
├── README.md              ← 数据字典(每张表 + 每个字段 + 枚举值含义)
│
├── ── 必填 5 类(召回核心)──
├── tb_patient.csv         ← SELECT * 原样
├── tb_appointment.csv
├── tb_encounter.csv       ← + tb_dx.csv(诊断)+ tb_tx.csv(治疗)三表 1:N 嵌套
├── tb_dx.csv
├── tb_tx.csv
├── tb_charge_order.csv    ← + tb_order_item.csv(明细)1:N
├── tb_order_item.csv
├── tb_payment.csv
│
├── ── 🟠 强烈推荐 3 类(产品收集 — 业务方明确指出关键)──
├── tb_recharge.csv        ← 充值/储值/福利卡(LTV 必备,中国民营牙科核心)
├── tb_complaint.csv       ← 投诉记录(合规营销避雷)
├── tb_referral.csv        ← 转介绍记录(渠道归因)
│
└── ── 🟡 可选(有就给)── tb_refund / tb_consultation / tb_emr_document / ...
```

3.2 PAC 团队基于数据字典写 yaml:

```bash
cd apps/pac-service
mkdir -p data/friday/assemblers/
# 写 N 份 assembler.yaml(每个 canonical 资源一份)
# 模板见 data/jvs-dw/assemblers/*.yaml
```

每份 assembler.yaml 描述:
- `primary`:host 的主表名
- `field_mapping`:host 字段 → PAC canonical 字段
- `join_arrays`:1:N 嵌套(如 tb_encounter ← tb_dx[]/tb_tx[])
- `enum_mapping`:host 编码(`A01`)→ PAC 枚举(`endodontic`)

3.3 写 `manifest.yaml`:

```yaml
host_name:     friday
tenant_id:     t_friday_grp1      # 单集团静态 tenant;多品牌 host 改用 tenant_field + identity_namespace_field(源 brand 列 → source_unit)
amount_unit:   yuan
timezone:      Asia/Shanghai

tables:
  # 必填 5 类
  - { table: tb_patient,      file: tb_patient.csv }
  - { table: tb_encounter,    file: tb_encounter.csv }
  - { table: tb_dx,           file: tb_dx.csv }
  - { table: tb_tx,           file: tb_tx.csv }
  - { table: tb_appointment,  file: tb_appointment.csv }
  - { table: tb_charge_order, file: tb_charge_order.csv }
  - { table: tb_order_item,   file: tb_order_item.csv }
  - { table: tb_payment,      file: tb_payment.csv }
  # 🟠 产品收集(host 能给则导)
  - { table: tb_recharge,     file: tb_recharge.csv }
  - { table: tb_complaint,    file: tb_complaint.csv }
  - { table: tb_referral,     file: tb_referral.csv }

assemblers:
  - { file: assemblers/patient.yaml }
  - { file: assemblers/clinical_encounter.yaml }
  - { file: assemblers/appointment.yaml }
  - { file: assemblers/charge_order.yaml }
  - { file: assemblers/payment.yaml }
  # 🟠 产品收集(对应 canonical resource:recharge / complaint / referral)
  - { file: assemblers/recharge.yaml }
  - { file: assemblers/complaint.yaml }
  - { file: assemblers/referral.yaml }
```

### Phase 4:cold-import 联调

```bash
cd apps/pac-service

# 先 dry-run 看翻译效果(不写库)
pnpm sync -- --dir=./data/friday --dry-run

# 真写库(首跑 cursor 空 = 全量;cohort 分批 + 可并行,内存恒定)
pnpm sync -- --dir=./data/friday --no-recompute
#   榨资源:PAC_COHORT_CONCURRENCY=3 pnpm sync -- --dir=./data/friday --no-recompute
#   首跑完手动补 persona/plan:
pnpm recompute-persona -- --host=friday && pnpm recompute-plans -- --host=friday

# 验证 timeline
pnpm timeline -- --host=friday --tenant=t_friday_grp1 --pid=<host pid>

# 跑召回 selector 自检:确认该 host 数据形态真能进召回(识别 K00-K09 子场景)
# ⚠️ 当前已无 `verify-scenarios` npm script;自检命令以代码为准(verify-chain 等),且基于 demo 数据,真实 host 需重写期望集
```

> **统一 sync 入口**:`pnpm sync` 首跑(cursor 空)= 全量,之后 cron 增量,同一套 `importDirectory`。
> `pnpm cold-import` 保留为 legacy「忽略 cursor 强制全量」alias。

**数据装载是 PAC 三大数据通道之一**(跟 pull/push 共用 AssemblerEngine + 下游 pipeline)。
host 持续导出 + PAC 日级 `pnpm sync` 增量(cron 自动)即可作为产线长期数据通道。

### Phase 5:开 pull 周期 / push 实时

- **Push 实时**:✅ **已实装**(`/push/events` + `/push/rows` + HMAC 验签)。host 接好即可推,契约见 [Push 通道](/docs/integration/channel-push)。
- **批量 Pull cron**:❌ 仍 STUB(`runPull` 未实装);单患者即时刷新已可用。

Phase 4 的 cold-import 跟 pull / push 走**同一套下游 pipeline**(AssemblerEngine → Synthesizer → Parser → patient_facts),所以接通新通道只需换"如何拿到 raw 数据"那一段(读文件 → HTTP 拉 / 收 webhook),中下游零改动,**写好的 assembler.yaml 直接复用**。

---

## 2. 运维场景(密钥失落 / push 密钥轮换 / 软停)

### 2.1 appSecret 失落 → 重发

```bash
pnpm pac:host rotate-secret friday
# → stdout 显示新 appSecret(plaintext 仅一次),旧立即失效;立即同步给 host
```

### 2.2 Push 密钥轮换(平滑过渡)

```bash
pnpm pac:host rotate-push friday
# → stdout 显示新 pushSecret + total_keys,数组前插旧 key 仍接受 grace
#   host 切完 → 单独 DB 脚本清理旧 hash(本期不暴露专用 CLI)
```

### 2.3 软停 host(合同到期 / 安全事件)

```bash
pnpm pac:host deactivate friday
# → active=false,后续 auth 立即 10101 拒绝。
#   不删数据,patient_transactions / patient_facts / followup_plans 全留(审计需要)。
```

---

## 3. 接入完成判定标准

完成接入意味着:

- [ ] `pnpm pac:host create` 拿到新 host 的 id / appId / appSecret
- [ ] Host 用 appSecret 换出 JWT(code=0,有 accessToken)
- [ ] `pnpm sync -- --dir=<host 导出目录> --no-recompute` 全成功(failed=0,facts > 0)
- [ ] `pnpm timeline -- --pid=<host 某 patient>` 能看到完整 actual + planned 列表
- [ ] 10 子场景(K00-K09)selector 跑出来命中数 > 0(说明数据形态真的能进召回)

如果 sync 报 `[drift] xxx row rejected: <字段路径>` —— 那是 host 实际字段 / 枚举值跟 PAC 的 assembler.yaml 错位,调整对应 yaml 重跑即可(不需要 host 改任何东西)。

---

## 4. 数据从 host 拿到后的落点速查

```text
host 脱敏导出 → CSV/JSON 文件 + manifest.yaml
   ↓ scp / S3 / 邮件
data/<host>-<timestamp>/
   ↓ pnpm sync -- --dir=...
[1] hosts              (Admin 早已录入)
[2] patients               (upsert,主档)
[3] patient_transactions   (append-only,raw_payload 留 host 原文)
[4] patient_facts          (版本流,parser 衍生)
[5] sync_logs              (该批 import 的账本:status/factsEmitted/duplicates/failed)
   ↓ GET /pac/v1/patients/:id/timeline
客服可读 timeline
```

后续 W3+ 引入 Persona / Plan / PlanExecution 后,fact 层之上的画像 / 召回计划自然展开。
