---
title: 数据模型
description: PAC 数据库 18 个 model 的层次、写入模式、隔离纪律与关键索引。
icon: Database
---

> **真理源**:`apps/pac-service/prisma/schema.prisma`(表结构)、`packages/types/src/enums`(枚举闭集)。本页给层次、写入模式、隔离与索引纪律;**逐字段细节以 schema 为准**。三层数据流 / 状态机 / 触发见 [三层模型](/docs/architecture/three-layer-model)。

---

## 模型地图(18 model)

隔离基线统一是 **`host_id + tenant_id`**(进每张业务表唯一键 + 每条查询)。

| 表 | 层 | 写入模式 | 角色 |
|---|---|---|---|
| `hosts` | 顶层 | upsert(admin) | 接入宿主注册:换票凭据 + 出站 `pull_config` + push 验签密钥 + deep-link 模板。子表 `host_id` FK 到此 |
| `patients` | L1 | upsert | 患者主档,**只装身份**(name/phone/dob/病历号);跨诊所共享一行 |
| `patient_profiles` | L1 | upsert(1:1) | 患者非事实属性副表:合规标记(`do_not_contact`/`deceased`)+ 标签/备注 + 获客渠道 + 转介绍聚合 |
| `patient_relations` | L1 | upsert(边) | 患者—患者关系边(联系人/亲属/转介绍人);关系人本身也是 patient |
| `patient_return_visits` | L1 | upsert | 诊所回访任务记录(展示用)。**非临床 fact,不进召回信号** |
| `patient_transactions` | L1 | append-only | 操作账本:谁在哪个诊所对哪个主体做了什么。原文留底 |
| `patient_facts` | L1 | 版本流 | 事实单元(版本流),`fact.content` 是下游唯一真理源 |
| `personas` | L2 | 版本流 | 患者画像(每次重算新版本) |
| `persona_features` | L2 | 版本流(随父) | 画像特征条目(key + 描述 + 证据) |
| `persona_recompute_logs` | 横切 L2 | append-only | 画像重算执行账本 |
| `followup_plans` | L3 | 版本流 | 召回计划(patient 级,单一活跃) |
| `plan_reasons` | L3 | 子表(plan 1:N) | 召回理由清单(scenario / sub_key 维度 + 证据 + 打分 breakdown) |
| `plan_scripts` | L3 | 版本流(随父) | 客服参考话术(per-plan 单行,异步生成) |
| `plan_summaries` | L3 | 版本流(随父) | 详情页摘要卡片(per-plan × type) |
| `plan_executions` | L3 | append-only | 执行回写(客服通话结果),驱动 plan 状态机 |
| `plan_generation_logs` | 横切 L3 | append-only | 召回生成执行账本 |
| `agent_invocations` | 横切 | append-only | AI 调用审计账本(跨所有层共享) |
| `sync_logs` | 横切 L1 | append-only | 同步运行账本(cursor / reconcile / 计数) |

> PAC **不立** tenants / clinics / users 表 —— 这三个 id 都是宿主经换票 token 提供的 `TEXT`,PAC 不维护其主数据(见 [架构总览](/docs/architecture/overview) · 多租户与权限)。

---

## 设计原则

**1 · 按表语义隔离,不是统一三级。** `host_id + tenant_id` 是所有业务表的硬隔离基线;`clinic_id` 只在三种业务语义下立柱,不滥用:

| 维度 | 表 | 语义 |
|---|---|---|
| `clinic_id`(操作发生地) | `patient_transactions` / `plan_executions` | 业务字段,RBAC 用,**非 DB 隔离边界** |
| `target_clinic_id`(业务归属,nullable) | `followup_plans` | null = 集团统一客服池 |
| 不立 `clinic_id` | 身份 / 推断层(persona / plan)/ 账本 | 跨诊所共享身份 |

**2 · 事实 vs 推断分层。** 事实(宿主给的结构化记录)落 `patient_transactions` + `patient_facts`;推断(PAC 算的结论)落 `persona_features` + `followup_plans`。**推断绝不回写事实** —— `plan_executions` 只驱动 plan 状态机,不进 fact/transaction(否则 PAC 自己的动作会被当患者事实参与下一轮重算,证据链就乱)。

**3 · 写入模式按数据所有权三分。**

| 模式 | 表 |
|---|---|
| **append-only**(只插不改不删) | `patient_transactions` / `plan_executions` / `agent_invocations` / 3 张 orchestration log |
| **upsert 当前态** | `patients` / `patient_profiles` / `patient_relations` / `patient_return_visits` / `hosts` |
| **版本流**(append + supersede) | `patient_facts` / `personas`(+features)/ `followup_plans`(+scripts/summaries) |

子表跟随父版本:`persona_features` 随 `personas`、`plan_scripts`/`plan_summaries` 随 `followup_plans`;父表 supersede 时子表自然进入历史。

**4 · 证据链与版本化。** 每个对外输出都能反查"为什么这么定":

```
persona_features.evidence = { factIds, agentInvocationIds, ruleIds }
plan_reasons.evidence     = { factIds, agentInvocationIds, ruleIds }
plan_scripts / plan_summaries.agentInvocationId
patient_facts.transactionIds → patient_transactions.id → rawPayload
```

**5 · Harness 工程观(规则为骨、AI 为肉)。** 数值 / 决策 / 时间 / 状态机走确定性规则(不允许失败);自然语言(话术 / 摘要)走 LLM,**失败必降级模板**(`source='template_fallback'`)。三张 orchestration log 立柱 `rule/agent/fallback` 计数,`fallbackCount` 升高 = 上游 LLM 异常早期信号。

**6 · 立柱 / JSON / notes 三分。** 需 SQL 筛选 / 排序 / 索引或属合规边界 → 立柱;易变、形状不固定、代码要 parse 的结构化数据 → JSON 列(应用层 zod 校验);仅展示给人的自由文本 → `notes`。

**7 · 金额一律 Int 分。** `*Cents` 字段以最小货币单位整数全链路存;入口按宿主 `amount_unit`(`fen`/`yuan`)归一,出口前端格式化,API 不返格式化字符串。

**8 · 时间一律 UTC 存储。** 时刻字段用 `DateTime @db.Timestamptz(3)`;日期(无时刻)用 `@db.Date`。入口按宿主 `timezone`(IANA)转 UTC,出口返 ISO 8601 with offset;跨日聚合 `AT TIME ZONE <tz>` 后再 `DATE_TRUNC`。

---

## 命名 / 类型 / 索引规约

- **命名**:表名 snake_case 复数;Prisma 字段 camelCase →(`@map`)DB 列 snake_case;主键统一 `id` UUID;同表内字段不重复表名前缀(fact 用 `kind`/`type`/`status`,不用 `factKind`);枚举值小写(`script` 不是 `SCRIPT`)。
- **类型**:主键 / FK = `String @db.Uuid`;时刻 = `Timestamptz(3)`;单调水位 = `BigInt @default(autoincrement())`;金额 = `Int`(分);枚举 = `String` + 应用层 zod 校验(DB 不强枚举,留扩展)。
- **partial UNIQUE / GIN**(Prisma 表达不了,migration.sql 是单一真理源):

| 约束 | 表 | 作用 |
|---|---|---|
| partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL` | `patient_transactions` | 幂等键 |
| partial UNIQUE `(host_id, tenant_id, subject_id) WHERE status='active'` | `patient_facts` | 单活跃版本 |
| partial UNIQUE `(host_id, tenant_id, patient_id) WHERE status IN ('active','assigned')` | `followup_plans` | patient 级单一活跃 plan |
| partial UNIQUE `(host_id) WHERE status='running'` | `sync_logs` | 同 host 存量/增量并发锁 |
| GIN `(transaction_ids)` | `patient_facts` | 反查"某 transaction 影响了哪些 fact" |

---

## 各表要点

### 第一层 · 患者事实

**`hosts`** — 接入宿主注册。`name` / `app_id` 唯一;`app_secret_hash` scrypt 哈希(原文不落库);`pull_config`(出站 endpoints + 凭据 + `amount_unit` + `timezone`,凭据对该宿主所有出站调用共用);`push_secret_hashes[]` 数组支持密钥平滑轮换(任一命中即通过);`action_urls` deep-link 模板;`active` 软停用(false 时换票直接 401)。

**`patients`** — 患者主档,**只装身份**。唯一键 `(host_id, tenant_id, source_unit, external_id)`。`active` 布尔(true=在召回池,false=宿主软删/归档);`name`/`phone` stub 期可空(push 首见只拿到 ref,后续 pull 补全;`phone IS NULL` 自动排除召回池);`medical_record_number` 可读病历号。**不支持物理删除**(PIPL 删除权走匿名化或 `active=false`),子表 `onDelete=Restrict` 兜底防误删。

> 召回池硬过滤:`active=true` AND `phone IS NOT NULL` AND(`patient_profiles`)`do_not_contact=false` AND `deceased=false`。

**`patient_profiles`** — 患者非事实属性副表(1:1,PK=`patient_id`,Cascade)。合规标记 `do_not_contact`(+原因/时间)、`deceased`;产品标签 `tags[]` + 自由 `notes`;获客渠道 `acquisition_channel`(立柱标准枚举,host 值经 enum_mapping 归一)+ `acquisition_sub`;转介绍聚合 `referral_count` / `referral_amount_cents`。拆出副表让主表身份查询更轻,合规过滤走 join。

**`patient_relations`** — 患者—患者关系边(联系人/亲属/转介绍人)。自关联:关系人本身也是 patient,姓名/电话从对端现取、零冗余。唯一键 `(patient_id, related_external_id, relationship)`;`related_external_id` 始终保留(晚绑定),`related_patient_id` 仅当关系人也是 active patient 时填。

**`patient_return_visits`** — 诊所回访任务记录(展示用)。唯一键 `(host_id, tenant_id, source_unit, external_id)`。**不是临床 fact、不进召回信号**;详情页"回访记录"按 `task_date` 倒序展示,避免客服重复外呼。

**`patient_transactions`** — 操作账本(append-only)。`event_seq` BigInt 单调水位(供 persona / 游标消费);`source_event_id` 幂等键(Push 必带,Pull adapter 合成);`raw_payload`(宿主原文)+ `canonical_payload`(assembler 翻译后中间态,审计/replay)+ `payload_hash`;`clinic_id` 立柱。`action` × `subject_type` 为 PAC 归一化封闭集 —— **23 个 action × 16 个 subject_type**(`@pac/types` `Action` / `SubjectType`)。

**`patient_facts`** — 事实单元(版本流)。`subject_id` 业务身份跨版本稳定,`(…, subject_id, version)` 单调递增;`kind`(`actual`/`planned`,2)× `type`(**16 个 FactType**)× `status`(`active`/`superseded`/`cancelled`/`fulfilled`/`expired`/`invalidated`,6);`content` JSONB(per type zod 校验,规则引擎只读这里);`transaction_ids` 证据链。一个 transaction 可产 0/1/N 个 fact。

### 第二层 · 患者画像

**`personas`** — 画像版本流。`source`(`txn:` / `manual:` / `scheduled:`)、`event_watermark`(已响应 `event_seq`,判 stale)、`superseded_at`(null=当前版)。不立 status enum(生命周期纯线性)。唯一键 `(patient_id, version)`。

**`persona_features`** — 画像特征(随父版本,Cascade)。`key` + `description`(自然语言,自包含,**不拆字段**)+ `score?` + `data?`(结构化值)+ `evidence`。唯一键 `(persona_id, key)`。候选 key 共 **33**(`PersonaFeatureKey`),v1 实装 **16 个规则 extractor**(`feature.registry.ts`),agent 路径未上线。

**`persona_recompute_logs`** — 重算执行账本。`triggered_by` / `status`(running/success/partial/failed)/ `rule_count`(agent/fallback 计数位 v1 恒 0)/ `produced_persona_id`。

### 第三层 · 召回计划

**`followup_plans`** — 召回计划版本流,**patient 级**(同 patient 任意时刻最多一条 active/assigned)。`priority_score` = MAX over `plan_reasons.priority_score`(排序冗余);`goal` / `recommended_at`(留 null)/ `recommended_role`(`staff`)/ `recommended_channel`(`phone`)纯规则;`status`(`active`/`assigned`/`completed`/`abandoned`/`superseded`,5);`assignee_user_id` / `recycle_at` / `snoozed_until`(抑制窗)/ `contact_attempts`(patient 级熔断)/ `recall_feedback`(召回准不准的拇指反馈);`target_clinic_id` 立柱。

**`plan_reasons`** — 召回理由子表(plan 1:N)。`scenario` + `sub_key`(临床缺口细分)+ `priority_score` + `reason`(自然语言)+ `evidence` + `signals`(结构化召回信号)+ `breakdown`(打分因子)+ `lifecycle`/`source`/`closed_reason`。唯一键 `(plan_id, scenario, sub_key)`。`PlanScenario` 枚举含 `treatment_initiation_recall`(应治未治)与 `treatment_aftercare_recall`(疗效保障),**当前仅 `treatment_initiation_recall` 在引擎注册运行**。

**`plan_scripts`** — 参考话术(per-plan 单行,Cascade)。`status`(`pending`/`ready`/`failed`)+ `source`(`agent`/`template_fallback`/`manual`)+ `agent_invocation_id`。唯一键 `(plan_id)`。

**`plan_summaries`** — 摘要卡片(per-plan × `type`,Cascade)。`SummaryType` 枚举 6 个;详情页当前用 3 张卡:`recall_brief`(本次召回简报)/ `persona_tags`(画像标签)/ `recall_history`(回访历史)。唯一键 `(plan_id, type)`。

> 话术 / 摘要按需懒生成(客服打开详情页时 `getOrGenerate`);`plan-asset-generate` 队列 worker 当前为 no-AI stub,详见 [三层模型](/docs/architecture/three-layer-model)。

**`plan_executions`** — 执行回写(append-only)。`channel` / `outcome` / `notes` / `abandon_reasons[]` / `scheduled_next_at`;`executor_clinic_id` 立柱(执行发生地,与 `target_clinic_id` 业务归属允许不一致)。`outcome` 共 **14 个**(表单可见 7,其余历史值),经 `EXECUTION_OUTCOME_META[outcome].drivesStatus`(`completed`/`abandoned`/`keep`)驱动 plan 状态机 + `suppressDays` 写抑制窗。

**`plan_generation_logs`** — 召回生成账本。`triggered_by` / `status` / `scenario_run_count` / `plans_created` / `assets_created` / `agent_count` / `fallback_count`。

### 横切支撑

**`agent_invocations`** — AI 调用审计账本(append-only,跨所有层共享)。`kind` / `call_key` / `prompt_version` / `model_provider` / `model_name` / `workflow_run_id` / `input_snapshot` / `output` / token 明细 / `cost_yuan` / `status`。每次 LLM 调用两段式落账(start=running → end 回填),是 audit / replay / 成本核算唯一真理源。AI 解析结果归 `output`,**不回写 fact**。

**`sync_logs`** — 同步运行账本。`direction`(pull/push)/ `resource` / `status`(running/success/partial/failed)/ `cursor_before`/`after`(断点续传)/ `triggered_by`(含 `reconcile:<date>` 对账)/ 计数恒等式 `fetched = transactions_written + duplicates + failed` + `facts_emitted` / `metadata`(cohort 进度 + 漂移审计 `suspectFields`)。

---

## 附录:真理源优先级

冲突时以高优先级为准:

1. **`schema.prisma`** —— 表结构 / 字段 / 索引(含 migration.sql 里的 partial UNIQUE / GIN)
2. **`packages/types/src/enums`** —— `action` / `subject_type` / `FactType` / `PlanScenario` / `ExecutionOutcome` / `SummaryType` 等枚举闭集
3. **本页 / 其他架构文档** —— 层次、写入模式、纪律的人读说明
