# PAC 服务器部署 + 数据摄入手册

> **W4 起试部署:服务器架构 + 数据从 DW 一次冷启 → 日级增量自动跑 → 客服可见的全链路 SOP**
> **目标读者**:运维 / 后端值班
> **状态**:🟢 W4 末投产基线版

---

## 一、服务架构(单机最小版)

```
┌────────────────────────────────────────────────────────────────┐
│ Server(2 核 8G 起步,生产建议 4 核 16G + SSD)                  │
│                                                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐             │
│  │ Postgres 15 │  │  Redis 7    │  │  pac-web    │             │
│  │  :5532      │  │  :6479      │  │  :3100      │  Next.js    │
│  │  pac DB     │  │ BullMQ /    │  │             │   prod build│
│  │             │  │ session     │  │             │             │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘             │
│         │                │                │                     │
│         └────────────────┼────────────────┘                     │
│                          │                                       │
│                  ┌───────▼────────┐                              │
│                  │ pac-service    │  NestJS                      │
│                  │  :3101         │   - HTTP API                 │
│                  │                │   - BullMQ Worker            │
│                  │                │   - Cron(增量同步/监控)    │
│                  └───────┬────────┘                              │
└──────────────────────────┼─────────────────────────────────────┘
                           │ TCP/9000(ClickHouse 协议)
                           ▼
              ┌────────────────────────┐
              │ 瑞尔 DW(阿里云 ADS)    │
              │ cc-xxx.clickhouse.aliyuncs.com:9000 │
              │ dw_group.fact_*_out     │
              └────────────────────────┘
```

### 组件清单

| 组件 | 端口 | 用途 | 数据敏感度 |
|---|---|---|---|
| Postgres 15 | 5532 | PAC 主数据(patients / facts / personas / plans / sync_logs) | 🔴 PII |
| Redis 7 | 6479 | BullMQ 队列 + 会话 | 🟡 session token |
| pac-service | 3101 | API + Cron + Worker | — |
| pac-web | 3100 | 前端(Next.js) | — |
| (远程)瑞尔 DW | 9000 | 数据源(只读),PAC 主动连 | 🔴 PII(只读) |

**NOT 部署在 PAC 这边**:
- 瑞尔 DW(host 那边维护)

---

## 二、首次部署 SOP

### 2.1 环境准备

```bash
# 1. Docker / Docker Compose
docker --version  # >= 24
docker compose version

# 2. Node.js 跟 pnpm(若不用 docker 跑 pac-service)
node --version  # >= 20
pnpm --version  # >= 9
```

### 2.2 拉代码 + 配 env

```bash
git clone <repo> /opt/pac && cd /opt/pac
cp apps/pac-service/.env.example apps/pac-service/.env
```

**必填 env**(`apps/pac-service/.env`):

```ini
# ─── 基础 ───
NODE_ENV=production
PORT=3101
LOG_LEVEL=info
DATABASE_URL=postgresql://pac:<pwd>@localhost:5532/pac?schema=public
REDIS_URL=redis://localhost:6479
JWT_SECRET=<openssl rand -hex 32>
JWT_REFRESH_SECRET=<openssl rand -hex 32>
CORS_ORIGINS=https://<your-pac-web-domain>

# ─── DW 直连 ───
JVS_DW_CLICKHOUSE_PASSWORD=<DW 团队给的密码>
# manifest.yaml 里 password_env: JVS_DW_CLICKHOUSE_PASSWORD

# ─── AI(DeepSeek)───
DEEPSEEK_API_KEY=<vendor 给的 key>
DEEPSEEK_BASE_URL=https://api.deepseek.com
AI_DEFAULT_MODEL=deepseek-v4-flash

# ─── 增量同步定时 ───
PAC_INCREMENTAL_CRON=30 2 * * *           # 每天 02:30(DW 02:00 刷完后)
PAC_INCREMENTAL_HOSTS=jvs-dw              # 多 host 用逗号分隔
PAC_INCREMENTAL_DATA_DIR=/opt/pac/apps/pac-service/data

# ─── Sync 资源调优(全可选,不设走默认)───
PAC_COHORT_BATCH_SIZE=5000                # 按患者分批(内存友好);0 = single-shot
PAC_COHORT_CONCURRENCY=1                  # cohort 并发度;1=串行(默认稳),3-4=并行榨资源
PAC_WRITE_BATCH_SIZE=1000                 # 每批 createMany/bulkWrite 行数;0 = per-row

# ─── 数据滞后告警阈值 ───
PAC_LAG_WARN_HOURS=24                     # > 24h 黄色 WARN
PAC_LAG_ERROR_HOURS=48                    # > 48h 红色 ERROR
```

> ⚠️ **Docker Compose 部署必带 `--env-file apps/pac-service/.env`**:
> compose 的 `${POSTGRES_PASSWORD}` 等变量插值从 *项目根 .env / shell* 读,**不读** service 的 `env_file`。
> 漏了 `--env-file` → 密码插值成默认 `pac` → migrate / 连库认证失败。
> `connection_limit`:并行 sync(concurrency>1)时 `DATABASE_URL` 加 `?...&connection_limit=20`,
> 否则并发 cohort 抢连接排队。

### 2.3 起 Postgres + Redis

```bash
docker run -d --name pac-postgres -p 5532:5432 \
  -e POSTGRES_USER=pac -e POSTGRES_PASSWORD=<pwd> -e POSTGRES_DB=pac \
  -v pac-pg-data:/var/lib/postgresql/data postgres:15

docker run -d --name pac-redis -p 6479:6379 \
  -v pac-redis-data:/data redis:7-alpine
```

### 2.4 构建 + 启动

```bash
cd /opt/pac
pnpm install --frozen-lockfile
pnpm --filter @pac/types build
pnpm --filter @pac/service exec prisma migrate deploy
pnpm --filter @pac/service exec prisma seed   # 写入 demo / jvs-dw host 行
pnpm --filter @pac/service build              # 编译 NestJS
pnpm --filter @pac/web build                  # Next.js prod build

# 起 pac-service(systemd / pm2 二选一)
cd apps/pac-service && node --enable-source-maps dist/src/main &

# 起 pac-web
cd apps/pac-web && pnpm start &
```

**Docker Compose 部署(prod,服务器实际用法)**:
```bash
cd ~/pac
# ⚠️ 全程带 --env-file(否则 ${POSTGRES_PASSWORD} 插值成默认值 → 认证失败)
# ⚠️ 代码更新后,pac-migrate 跟 pac-service 是【两个独立镜像】,都要 rebuild
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml build pac-migrate pac-service
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml up -d
# pac-migrate(restart:no)起来自动跑 prisma migrate deploy 后退出;pac-service 依赖它健康
# 验证迁移:docker compose ... logs pac-migrate | grep "migrations have been applied"
```

---

## 三、数据摄入 SOP

> **统一 sync 入口(2026-05-28 重构)**:存量 / 增量合并为单一 `pnpm sync`。
> 首跑(cursor 空)= 等价全量;之后 cron 增量。`pnpm cold-import` 保留为 legacy
> 「忽略 cursor 强制全量」alias,日常用 `pnpm sync`。

### 3.1 首次全量(`pnpm sync` 首跑)— 把全量历史拉进 PAC

**用途**:首次部署 / 重建库 / yaml 改大版本时

**机制**:cursor 为空 → 等价全量。**按患者 cohort 分批**(默认 5000/批),每批
load → transform → assemble → bulk write → 释放内存 → 下一批。内存恒定 ~500MB-1GB,
**不会 OOM**(对比旧 cold-import 一次性 5-10GB 撞墙)。

```bash
# docker 部署(prod)— 必带 --env-file
cd ~/pac
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml exec -T \
  -e PAC_COHORT_CONCURRENCY=3 \
  pac-service pnpm sync:prod -- --dir=./data/jvs-dw --cohort-batch=500 --no-recompute

# 本地 / 裸机
PAC_COHORT_CONCURRENCY=3 pnpm sync -- --dir=./data/jvs-dw --cohort-batch=5000 --no-recompute
```

参数:
- `--cohort-batch=N` — 每批 N 个患者(默认 5000;远程 DW 慢可调小 500 看进度更细)
- `PAC_COHORT_CONCURRENCY=3` — 3 个 cohort 并发(榨资源;默认 1 串行);需 `connection_limit` 配套
- `--no-recompute` — 只拉数据,persona/plan 单独跑(大数据量分阶段更可控)

**预估(13 万 patients,远程 DW)**:concurrency=3 ≈ 1.5-2h;内存峰值 ~800MB。
(对比旧串行 cold-import 9-12h 且撞 OOM/磁盘满)

**verify**:
```sql
SELECT 'patients' AS t, count(*) FROM patients
UNION ALL SELECT 'tx', count(*) FROM patient_transactions
UNION ALL SELECT 'facts', count(*) FROM patient_facts;
-- 完整性自检:应当都 = 0
SELECT count(*) AS dup_tx FROM (SELECT source_event_id,host_id,tenant_id FROM patient_transactions
  WHERE source_event_id IS NOT NULL GROUP BY 1,2,3 HAVING count(*)>1) x;
SELECT count(*) AS multi_active FROM (SELECT subject_id,host_id,tenant_id FROM patient_facts
  WHERE status='active' GROUP BY 1,2,3 HAVING count(*)>1) y;
```

全量完成后,**手动触发 persona + plan**(增量模式 cron 会自动联动,首跑用 --no-recompute 时手动补):
```bash
pnpm recompute-persona:prod -- --host=jvs-dw   # 13万 ~45min
pnpm recompute-plans:prod -- --host=jvs-dw      # ~10min
```

### 3.2 日级增量(自动跑,无需人工)

**机制**:`SyncIncrementalSchedulerService` `@Cron`,服务起来自动注册,走同一个
`importDirectory`(存量增量同一套代码)。

```
每天 02:30(PAC_INCREMENTAL_CRON)
   ↓
importDirectory({ incremental: true })
   - 并发锁:sync_logs partial UNIQUE (host_id) WHERE status='running'
     同 host 已在跑 → skip(SyncAlreadyRunningError),等下次 cron
   - 读 sync_logs 上次 cursor_after → SQL WHERE updated_date > cursor(增量;数据量小通常 1 批)
   - 拉新/改 fact + 反向拉主档(C 方案)+ stub auto-create(A 方案)
   - ⭐ 写 cursor_after = run_start ISO(不是 max(updated_date))
     保证跑期间 DW 任何新写入,下次都能捞回(不漏批次间变更)
   ↓
Persona recompute(本次涉及 distinct patient)
   ↓
Plan recompute(per tenant — SQL 召回 + 6 因子打分)
```

**结果可见**:DW 今天新数据 → 明早 03:00 ± 客服工作台可见。

**手动触发**(debug / 紧急补跑):
```bash
pnpm sync -- --dir=./data/jvs-dw                 # 增量 + 联动重算
pnpm sync -- --dir=./data/jvs-dw --no-recompute  # 只拉数据
pnpm sync -- --dir=./data/jvs-dw --dry-run       # 不写库,看 cursor 注入
pnpm sync -- --dir=./data/jvs-dw --full          # 忽略 cursor 强制全量(灾后)
```

### 3.3 数据完整性保证

**幂等(DB 双 UNIQUE)** — 任何并发 / 重跑都一致:
- `patient_transactions` partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE NOT NULL`
  (migration 20260528000002 补;之前漏建,createMany skipDuplicates 曾失效)
- `patient_facts` UNIQUE `(host_id, tenant_id, subject_id, version)` + active partial UNIQUE

**source_event_id 含 updatedAt** → DW 行级 in-place 更新自动产生新 tx + 新 fact 版本
(supersede 旧版);同行同 updatedAt → 幂等 skip。

**A+C 双修**(patient_id 不丢):
- **A Patient Stub Auto-Create**:增量拉 fact 但主档 cursor 没动 → 用三段键建空 stub,真实主档 upsert 补 PII
- **C 反向拉主档**:跑完 fact 收集 `(patient_id, brand)` → `WHERE (patient_id,brand) IN (...)` 强拉主档同 run
- 实测:修前 51% tx patient_id null → 修后 **0%**

**cohort 宿主无关** — `manifest.sql_source.cohort` 声明 `patient_list_from` / `patient_key_column`
/ `tenant_key_column` / `list_cursor_column`,代码不硬编码表名列名。新 host 改 yaml 即可。

### 3.4 资源调优(并行 / 内存 / 速度)

| 旋钮 | env | 作用 | 建议 |
|---|---|---|---|
| cohort 大小 | `PAC_COHORT_BATCH_SIZE` / `--cohort-batch=N` | 每批患者数(控内存) | 5000;远程 DW 看进度可 500 |
| cohort 并发 | `PAC_COHORT_CONCURRENCY` | 并发 cohort 数(榨资源) | 默认 1 稳;3-4 并行(需 connection_limit) |
| 写批大小 | `PAC_WRITE_BATCH_SIZE` | 每次 createMany 行数 | 1000;0=退回 per-row(回滚开关) |
| query 并行 | (自动) | N 个 CH query Promise.all | load 阶段 ~4x,无需配置 |

**并行数据完整性**:cohort = disjoint 患者集,所有写患者级隔离 + DB 双 UNIQUE 兜底
→ 任何并发下一致。并行模式 checkpoint resume 禁用(靠幂等从头重跑)。

---

## 四、监控 / 告警

### 4.1 DW 数据滞后监控

`DwLagMonitorService` `@Cron(HOURLY)` 自动跑:

```
读最近一次 incremental_bundle.cursor_after JSON
   ↓
算 max(cursor) vs now() 差 (小时)
   ↓
🟢 < 24h    LOG info
🟡 24-48h   LOG warn  (DW 没刷新,提醒值班)
🔴 > 48h    LOG ERROR (严重滞后,事件可能漏召)
```

阈值改 env:`PAC_LAG_WARN_HOURS` / `PAC_LAG_ERROR_HOURS`。

**接告警通道**(W5+ TODO):log ERROR 后接 webhook → 钉钉/飞书。当前阶段值班看 `journalctl -u pac-service | grep dw-lag` 即可。

### 4.2 BullMQ Bull Board(看队列健康度)

`/admin/queues` 路径(需登录 admin),实时看:
- persona-recompute / plan-recompute / plan-asset-generate 三队列
- 各 worker 处理速度 / 失败重试 / 死信

### 4.3 关键 SQL 检查

```sql
-- 最近一次 sync 结果(应当 success / failed=0)
SELECT to_char(started_at,'YYYY-MM-DD HH24:MI') AS t, resource, status, fetched, failed, error_message
FROM sync_logs ORDER BY started_at DESC LIMIT 5;

-- 数据规模
SELECT 'patients' AS k, count(*) FROM patients
UNION ALL SELECT 'facts', count(*) FROM patient_facts
UNION ALL SELECT 'active_plans', count(*) FROM followup_plans WHERE status IN ('active','assigned');

-- 数据滞后(跟 monitor 一致)
SELECT to_char(started_at,'HH24:MI') AS sync_t, cursor_after
FROM sync_logs WHERE resource='incremental_bundle' AND status='success'
ORDER BY started_at DESC LIMIT 1;
```

---

## 五、运维常见操作

| 场景 | 命令 |
|---|---|
| **重启 pac-service** | `systemctl restart pac-service`(或 pm2 reload pac-service) |
| **手动补跑昨日增量** | `pnpm sync-incremental -- --dir=./data/jvs-dw` |
| **某 patient 召回不对,临时单刷** | `pnpm recompute-plans -- --host=jvs-dw`(全 host)/ 详情页 "刷新" 按钮(单 patient) |
| **看某 patient 治疗链** | `pnpm exec ts-node src/cli/verify-chain.cli.ts --id=<uuid>` |
| **改 scenario SQL 后强制重算 plan** | `pnpm recompute-plans -- --host=jvs-dw`(scenario 改口径、facts 不变,无需 reparse) |
| **改 yaml 字典后补存量 fact(reparse)** | `pnpm reparse -- --host=jvs-dw --subject-type=<canonical> [--patient=..] [--dry-run] [--no-recompute]`(离线、非破坏,见 §六bis)|

---

## 六、Yaml 变更管理 SOP

数据/术语 yaml 改了(`apps/pac-service/data/<host>/`)?

| 改动类型 | 影响 | 处理 |
|---|---|---|
| **enum_mapping / keyword_mapping 加/改术语** | 新数据生效,老数据需 reparse | **`pnpm reparse -- --subject-type=<canonical>`**(§六bis;不用全量重摄)|
| **field_mapping 改字段名** | 同上 | 同上 |
| **transforms 改 split/derive** | 影响管道 | 同上(reparse 会重跑 transforms)|
| **scenario SQL 改召回口径** | 立即生效(下次 cron 跑就用) | 等 cron 或手动 `recompute-plans`(facts 不变,无需 reparse)|
| **canonical-codes.ts 改字典** | 立即生效(chain composer 用) | 重启 pac-service |

---

## 六bis、Reparse — yaml 字典改了补存量 ⭐(不用全量重摄)

改了 assembler yaml(enum_mapping / keyword_mapping / field_mapping / transforms)后,要把**已入库的老
fact 按新口径重新衍生** —— **不用** truncate + 全量重摄(那会拉 DW ~2h 且 `TRUNCATE` 删掉
plan_executions / 分配)。`reparse` 用已存的 `transaction.rawPayload`(host 原始源行)**离线**重跑
transformEngine + assembler + parser:

- **非破坏**:fact 走版本流(内容变 → supersede 升版本,不变 → 幂等跳过);不 truncate、不碰 transaction
  账本 → 客服执行历史 / 分配全保住。
- **不连 DW**:rawPayload 在本地 → 比全量重摄快一个量级。
- **可圈定**:按 host / subject_type / 患者集 / dry-run / 是否重算。
- **覆盖**:field/enum/keyword_mapping + transforms 算子 + parser 改动(长尾字典修补 ~99%)。
  **不覆盖**:改了 CH `sql_source` 的 SELECT 本身(要从 DW 多拉原来没拉的列/表)→ 那才需真重摄;
  非 transform 产出的资源(如 `image_finding_rows` = CH SQL 视图)会**自动跳过**(rawPayload 非其装配输入)。

### 用法

```bash
# dry-run:报 scope(患者数 / txn 数),不写库
pnpm reparse:prod -- --host=jvs-dw --subject-type=diagnosis --dry-run

# 实跑全量某资源(按患者分批,内存恒定;自动定向重算【变更患者】的 persona/plan)
pnpm reparse:prod -- --host=jvs-dw --subject-type=diagnosis

# 限定患者(快):只重解析这些患者(改 keyword 只影响一小批时用,秒级)
pnpm reparse:prod -- --host=jvs-dw --subject-type=diagnosis --patient=<uuid>,<uuid>,...

# 只重衍 fact、不重算(超大批量时,稍后单独 pnpm recompute-plans:prod)
pnpm reparse:prod -- --host=jvs-dw --subject-type=diagnosis --no-recompute

# docker 部署(prod)容器内 detached 跑 + 看日志
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml \
  exec -d pac-service sh -c "pnpm reparse:prod -- --host=jvs-dw --subject-type=diagnosis > /tmp/reparse.log 2>&1"
docker compose -f docker-compose.prod.yml exec -T pac-service tail -f /tmp/reparse.log
```

参数:
- `--subject-type=<canonical>` — 限定资源(diagnosis / treatment_actual …);空 = 全部 transform 产出的 subject 资源。
- `--patient=<uuid,...>` — 限定患者集(秒级);空 = 全 host(分批)。
- `--dry-run` / `--no-recompute` — 只报 scope / 只重衍 fact 不重算。
- env `PAC_REPARSE_BATCH`(默认 3000)— 全量时每批患者数(控内存)。

### 实测基线(瑞尔全量 diagnosis,116809 患者)

- 541k 诊断 / 290k EMR 源行,分 39 批,**内存恒定 ~630MB**(峰值 1.6G,远低于 8G heap);
- fact 重衍 ~18min(只动变更的、其余幂等跳过)+ 定向重算变更患者 ~10min;
- 单患者 / 小批 `--patient`:**秒级~分钟级**(274 患者 ~46s)。

### 典型场景:诊断术语漏 code → 漏召回(本能力第一个用例)

host 中文诊断名变体极多(实测 98% 无 ICD 码,全靠 enum/keyword 白名单翻译),漏映射 → `code=null`
→ 进不了该病种召回(FN)。修法:`diagnosis.yaml` 加 keyword → `pnpm reparse:prod -- --subject-type=diagnosis`。
**案例**:正畸"反合 / 开合 / 错牙合 / 牙列间隙"等变体补 K07,全院正畸漏召 **5,554 → 1**,新增 ~1,600 条正畸召回。

---

## 七、灾备 / 回滚

### 7.1 数据备份

```bash
# 每天凌晨 04:00 备份(增量 03:30 完成后)
pg_dump -U pac -d pac -F c -f /backup/pac-$(date +%F).dump
# 保留 7 天
find /backup -name 'pac-*.dump' -mtime +7 -delete
```

### 7.2 回滚代码版本

```bash
cd /opt/pac && git fetch && git checkout <last-known-good-tag>
pnpm install && pnpm --filter @pac/types build && pnpm --filter @pac/service build
systemctl restart pac-service
```

### 7.3 cursor 倒退(数据要重跑某窗口)

```sql
-- 把最近一次 sync_log 的 cursor_after 改到目标时间
UPDATE sync_logs SET cursor_after = '{"fact_emr_treatment_out":"2026-04-01",...}'
WHERE id = (SELECT id FROM sync_logs WHERE resource='incremental_bundle' AND status='success' ORDER BY started_at DESC LIMIT 1);
```

下次 cron 自动从这个 cursor 拉。或直接 `pnpm sync -- --full` 忽略 cursor 全量重拉(幂等,UPSERT skip 已有)。

### 7.4 stale 'running' 锁清理(进程崩溃留下)

并发锁是 sync_logs `status='running'` 行;进程崩溃没 finalize 会留 stale 锁挡住后续 sync。
```sql
-- 看门狗:running 超 12h 视为 stale,人工 abort 释放锁
UPDATE sync_logs SET status='aborted', ended_at=NOW()
WHERE status='running' AND started_at < NOW() - INTERVAL '12 hours';
```
(CLI 撞锁退出 code=4;日志含 existing sync_log id 便于定位)

---

## 八、当前已知边界

| 项 | 现状 | 影响 | 何时解决 |
|---|---|---|---|
| ~~reparse 模式缺~~ ✅ | `pnpm reparse` 离线重解析(raw_payload 重跑装配+解析,不连 DW、非破坏,见 §六bis)| 改 yaml 字典秒级~分钟级补存量,不用全量重摄 | **已落地**(原 task #46)|
| 诊断 code 长尾待评估 | 松动牙(~1100)/ 种植体周围炎(~290)等真病暂未映射 code | 前者映射 K05 有过召风险(含生理性松动)、后者无对应召回 scenario | 待业务/产品决策(种植体周围炎或可开"种植维护"召回场景)|
| 告警接通道 | log ERROR + 人工看 | 滞后无即时告警 | W5+ 加 webhook 钉钉/飞书 |
| 并行 stale 锁清理 | 人工 SQL(7.4) | 崩溃后需手动 abort | W5+ 加 cron 看门狗自动清 |
| COPY 未上 | 用 prisma createMany(已 10-20x) | 极限性能还差 COPY 的 3-5x | PR5c(可选,按需) |
| 多 host 部署 | 支持(`PAC_INCREMENTAL_HOSTS=jvs-dw,friday`)| — | 已就绪 |
| 单机 vs 集群 | 当前单机 | 高 QPS 时 pac-service 可水平扩展(无状态) | 试点期不需要 |

---

## ▎一句话归纳

> **PAC 部署 = Postgres + Redis + pac-service + pac-web 四件套(docker compose 必带 `--env-file`)。数据摄入 = 统一 `pnpm sync`:首跑全量(cohort 分批 + 可并行)+ 日级 cron 增量(同一套代码)。幂等靠 DB 双 UNIQUE,内存靠 cohort batch 恒定,速度靠 query 并行 + bulk write + cohort 并发。监控 = lag monitor + Bull Board + 日常 SQL 检查。**
