Commit d7d0f342 by luoqi

docs(refactor): 据评审收敛 — 通用 source_unit / unit_type 非层级 / 多集团 / 定纵深防御

- 患者命名空间限定符 brand → 通用 source_unit(manifest 声明源字段,brand 仅瑞尔实例)
- org_units.unit_type 字符串枚举且 ≠ 层级:层级靠 parent_id 树,支持多级区域/无品牌等任意形状
- host:集团=1:N 确认,现有 schema 已支持,无需改
- 集团 ID 三来源:宿主 group_id 透传 / PAC 配 tenant_map→集团 / 必填定义;集团 UUID 新发
- 纵深防御定稿:Prisma $extends + AsyncLocalStorage,RLS 作 P2+ 硬化

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 5cb3f2c7
...@@ -40,7 +40,8 @@ patient 身份 = (host, tenant=集团, brand, external_id) ← brand 只消歧 ...@@ -40,7 +40,8 @@ patient 身份 = (host, tenant=集团, brand, external_id) ← brand 只消歧
golden 客户 = MPI 集团内跨品牌归一(独立上层,本次不做) golden 客户 = MPI 集团内跨品牌归一(独立上层,本次不做)
``` ```
- host 不拆正交轴,保持"接入宿主";`host : 集团(tenant)` 概念上 1:N(多集团 SaaS 当 host 时),现实 1:1。 - host 不拆正交轴,保持"接入宿主";**`host : 集团(tenant) = 1:N`**(一个 host 下多个集团)。
-**现有 schema 已支持**:`(host_id, tenant_id)` 本就允许一 host 挂多 tenant,多集团**无需改 schema**,`tenant_id=集团`直接多值。
- 隔离墙在**集团**;集团内品牌间默认隔离、**可配置打开** - 隔离墙在**集团**;集团内品牌间默认隔离、**可配置打开**
--- ---
...@@ -81,17 +82,23 @@ golden 客户 = MPI 集团内跨品牌归一(独立上层,本次不做) ...@@ -81,17 +82,23 @@ golden 客户 = MPI 集团内跨品牌归一(独立上层,本次不做)
## 4. Schema 改造 ## 4. Schema 改造
### 4.1 新增 `patients.brand` 列(命名空间限定符) ### 4.1 新增 `patients.source_unit` 列(通用命名空间限定符,**不写死 brand**)
> ⚠️ brand 不通用:别的 host 可能根本没"品牌",而是 集团→区域→诊所。
> 患者键里的消歧符应是**通用的"源组织命名空间"**,具体对应哪一层由 manifest 声明。
```prisma ```prisma
model Patient { model Patient {
// ... // ...
brand String? @map("brand") // 品牌命名空间;NULL=单品牌 host sourceUnit String? @map("source_unit") // 源组织命名空间值(瑞尔=brand值;别的host=区域码/HIS实例…)
// 唯一键:加 brand // 唯一键:加 source_unit
@@unique([hostId, tenantId, brand, externalId]) // 原 (hostId, tenantId, externalId) @@unique([hostId, tenantId, sourceUnit, externalId]) // 原 (hostId, tenantId, externalId)
} }
``` ```
- 回填:`brand` 从现 `tenant_id` 反查(`ba67e6cf→瑞尔``77057aed→瑞泰`)。 - `source_unit` = manifest 指定的"身份命名空间字段"原值。jvs-dw 此字段 = `brand`(瑞尔/瑞泰)。
- 改键后再把 `tenant_id` 改成集团 UUID(顺序见 §8)。 - 回填:`source_unit` 从现 `tenant_id` 反查(`ba67e6cf→瑞尔``77057aed→瑞泰`)。
- 改键后再把 `tenant_id` 改成集团 UUID(顺序见 §9)。
-`org_units` 的关系:`source_unit` 值 = 对应 org 节点的 `external_ref`(瑞尔场景是 brand 节点;别的 host 可能是 clinic 节点——取决于源系统 external_id 在哪一层唯一)。
### 4.2 新增 `org_units` 组织树(本次唯一新实体表) ### 4.2 新增 `org_units` 组织树(本次唯一新实体表)
```prisma ```prisma
...@@ -100,19 +107,27 @@ model OrgUnit { ...@@ -100,19 +107,27 @@ model OrgUnit {
hostId String @map("host_id") @db.Uuid hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id") // = 集团 tenantId String @map("tenant_id") // = 集团
parentId String? @map("parent_id") @db.Uuid // 自关联,树 parentId String? @map("parent_id") @db.Uuid // 自关联,树
nodeType String @map("node_type") // 'group'|'brand'|'region'|'clinic' unitType String @map("unit_type") // 'group'|'brand'|'region'|'clinic'… 字符串枚举
externalRef String? @map("external_ref") // 宿主侧 brand码 / clinic_id externalRef String? @map("external_ref") // 宿主侧 brand码 / 区域码 / clinic_id
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent OrgUnit? @relation("OrgTree", fields: [parentId], references: [id]) parent OrgUnit? @relation("OrgTree", fields: [parentId], references: [id])
children OrgUnit[] @relation("OrgTree") children OrgUnit[] @relation("OrgTree")
@@unique([hostId, tenantId, nodeType, externalRef]) @@unique([hostId, tenantId, unitType, externalRef])
@@index([hostId, tenantId, nodeType]) @@index([hostId, tenantId, unitType])
@@index([parentId]) @@index([parentId])
} }
``` ```
> **⭐ `unit_type` ≠ 层级**:层级完全靠 `parent_id` 树,`unit_type` 只是**分类标签**。
> 所以**任意深度、任意形状、同 type 可嵌套**都支持:
> - 瑞尔:集团 → 品牌 → 诊所
> - 别的 host:集团 → 区域 → 区域 → 诊所(**无品牌、多级区域**)
> - 再别的:集团 → 事业部 → 品牌 → 城市 → 诊所
> 一律装得下。`unit_type` 用**字符串枚举**(`group|brand|region|clinic|division…`),**不用数字**(数字脆、不可读、扩类型要改一堆)。
> 子树查询性能:可加 `path`(物化路径,如 `/g1/b2/c9`)或用 Postgres `ltree`,避免递归 CTE。
> **设计决策(待定):org_units 的"名字"放哪?** > **设计决策(待定):org_units 的"名字"放哪?**
> PAC 现行原则是「不立 tenants/clinics/users 主数据表,只存 id,展示名走宿主字典」([schema.prisma:18-20])。 > PAC 现行原则是「不立 tenants/clinics/users 主数据表,只存 id,展示名走宿主字典」([schema.prisma:18-20])。
> 建议沿用:`org_units` 只存**结构**(id/parent/type/external_ref),**不存 name**;name 仍由宿主登录字典(`dictionary.clinics`)解析。 > 建议沿用:`org_units` 只存**结构**(id/parent/type/external_ref),**不存 name**;name 仍由宿主登录字典(`dictionary.clinics`)解析。
...@@ -145,22 +160,36 @@ cohort: ...@@ -145,22 +160,36 @@ cohort:
tenant_key_column: brand tenant_key_column: brand
``` ```
### 目标 ### 集团 ID 哪里来 —— 三种情况
| 情况 | manifest 配置 |
|---|---|
| 宿主数据**有** group_id | `tenant_field: group_id`(pass-through,FieldPassThroughResolver) |
| 宿主**没有**(jvs-dw 只有 brand) | `tenant_field: brand` + `tenant_map: {瑞尔→集团UUID, 瑞泰→集团UUID}`(两品牌映同一新发集团) |
| 都没有、归属未知 | **无法路由,必须先定义** —— 集团归属是业务事实,接新 host 时必填 |
> 集团 UUID **新发**,不复用品牌 UUID(避免语义混淆)。
> 多集团 host:`tenant_map` 里不同品牌映不同集团即可(如 `{A→集团1, B→集团1, C→集团2}`)。
### 目标(jvs-dw,宿主无 group_id)
```yaml ```yaml
tenant_id: <集团UUID> # 静态 → StaticTenantResolver,所有行 = 集团 tenant_field: brand # 复用 FieldMapTenantResolver,但 map 改成 →集团
brand_field: brand # 【新增】把 brand 当独立列捕获,落 patients.brand tenant_map:
瑞尔: <集团UUID> # 两品牌都映到同一个新发集团
瑞泰: <集团UUID>
identity_namespace_field: brand # 【新增】把 brand 当独立命名空间捕获 → patients.source_unit
cohort: cohort:
patient_key_column: patient_id patient_key_column: patient_id
tenant_key_column: brand # cohort 仍按 (patient_id, brand) 防撞号取数,不变 tenant_key_column: brand # cohort 仍按 (patient_id, brand) 防撞号取数,不变
``` ```
### 代码改动点 ### 代码改动点
- `manifest.schema.ts`:新增可选 `brand_field` - `manifest.schema.ts`:新增可选 `identity_namespace_field`
- `tenant-resolver.ts`:不动(已支持静态)。 - `tenant-resolver.ts`:**不动**(FieldMapTenantResolver 已能 brand→任意值,map 改成→集团即可)。
- **患者 upsert 落 brand**(4 处): - 新增**命名空间解析**:按 `identity_namespace_field` 从行取 `source_unit` 值(与 tenant 路由解耦)。
- **患者 upsert 落 source_unit**(4 处):
- `pipeline/pipeline-dispatcher.service.ts:244` - `pipeline/pipeline-dispatcher.service.ts:244`
- `cold-import/cold-import.service.ts:1314 / 1494 / 1896` - `cold-import/cold-import.service.ts:1314 / 1494 / 1896`
-`where: { hostId_tenantId_externalId }``where: { hostId_tenantId_brand_externalId }`,create/update 带上 `brand` -`where: { hostId_tenantId_externalId }``where: { hostId_tenantId_sourceUnit_externalId }`,create/update 带上 `source_unit`
--- ---
...@@ -184,10 +213,12 @@ if (policy.shareAcrossBrand === false) where.brand = scope.brand; ...@@ -184,10 +213,12 @@ if (policy.shareAcrossBrand === false) where.brand = scope.brand;
- **默认 closed**,确保上线即与现状行为一致、不误开放。 - **默认 closed**,确保上线即与现状行为一致、不误开放。
### 6.3 纵深防御(墙变软,必须补) ### 6.3 纵深防御(墙变软,必须补)
现状**零 Prisma 中间件 / 零 RLS**,隔离全靠手写 `where` 注入。tenant→集团后品牌过滤也是手写,**漏一处 = 跨品牌泄漏**。补一层兜底: 现状**零 Prisma 中间件 / 零 RLS**,隔离全靠手写 `where` 注入。tenant→集团后品牌过滤也是手写,**漏一处 = 跨品牌泄漏**
- 方案 A:`prisma.service.ts``$extends({ query })`,用 AsyncLocalStorage 注入当前集团,对 find*/count/groupBy 自动加 `tenantId` 过滤(漏写也兜得住)。
- 方案 B:Postgres RLS,`tenant_id` 行级策略。 **✅ 已定:Prisma `$extends` 客户端扩展 + AsyncLocalStorage 租户上下文为主,RLS 作为后续硬化(P2+)。**
- 建议先 A(应用层、改动小、可灰度),RLS 作为后续硬化。 - `prisma.service.ts``$extends({ query })`,从 ALS 取当前集团,对 find*/count/groupBy **自动注入 `tenantId` 过滤**(漏写也兜得住),并对未带租户上下文的查询告警。
- 理由:PAC 每服务单连接池,应用层兜底够;RLS 需按连接设 session 变量、与连接池配合麻烦、调试黑;中间件可增量加、无需 DB 迁移、能灰度。
- RLS 留作规模/合规要求上来后的二次硬化,不阻塞本次。
--- ---
...@@ -235,16 +266,16 @@ interface TenantScopeContext { ...@@ -235,16 +266,16 @@ interface TenantScopeContext {
> 现状本地:瑞尔 83,834 + 瑞泰 11,166 患者,472 撞号。线上同构。 > 现状本地:瑞尔 83,834 + 瑞泰 11,166 患者,472 撞号。线上同构。
1. **加列**:`patients.brand`(nullable),不动数据。 1. **加列**:`patients.source_unit`(nullable),不动数据。
2. **回填 brand**:`UPDATE patients SET brand = CASE tenant_id WHEN 'ba67e6cf…' THEN '瑞尔' WHEN '77057aed…' THEN '瑞泰' END` 2. **回填 source_unit**:`UPDATE patients SET source_unit = CASE tenant_id WHEN 'ba67e6cf…' THEN '瑞尔' WHEN '77057aed…' THEN '瑞泰' END`
3. **审撞号**:确认 `patient_facts.subject_id``followup_plans.patient_id` 跨品牌无撞(patient_id 是 UUID,安全;subject_id 需查)。 3. **审撞号**:确认 `patient_facts.subject_id``followup_plans.patient_id` 跨品牌无撞(patient_id 是 UUID,安全;subject_id 需查)。
4. **改唯一键**:`patients``UNIQUE(host, tenant, brand, external_id)` 4. **改唯一键**:`patients``UNIQUE(host, tenant, source_unit, external_id)`
5. **重映射 tenant_id**:所有 15 张带 tenant_id 的表 `tenant_id` 品牌UUID → 集团UUID(事务内,按表批量)。 5. **重映射 tenant_id**:所有 15 张带 tenant_id 的表 `tenant_id` 品牌UUID → 集团UUID(事务内,按表批量)。
6. **建 org_units**:从 distinct brand / clinic_id 派生节点(group 根 + brand + clinic)。 6. **建 org_units**:从 distinct source_unit / clinic_id 派生节点(group 根 + brand/region + clinic)。
7. **切 manifest**:静态 group + `brand_field` 7. **切 manifest**:`tenant_map`→集团 + `identity_namespace_field`
8. **回归**:全量重算 plan/persona 校验 99%+ 不变(同上次部署的 plansUnchanged 验证法)。 8. **回归**:全量重算 plan/persona 校验 99%+ 不变(同上次部署的 plansUnchanged 验证法)。
> 472 撞号在第 4 步后由 brand 列分开,第 5 步合 tenant **不产生合并**。可逆:保留 brand→旧 tenant 映射即可回退。 > 472 撞号在第 4 步后由 source_unit 列分开,第 5 步合 tenant **不产生合并**。可逆:保留 source_unit→旧 tenant 映射即可回退。
--- ---
...@@ -263,11 +294,11 @@ P0+P1 即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌 ...@@ -263,11 +294,11 @@ P0+P1 即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌
## 11. 待决策 / 开放问题 ## 11. 待决策 / 开放问题
1. **org_units 存不存 name**?建议不存(沿用"宿主字典"原则),但若要做集团级报表/树形 UI,可能需要 PAC 侧缓存名。→ 倾向:结构 PAC 拥有,名走字典。 1. **org_units 存不存 name**?建议不存(沿用"宿主字典"原则),但若要做集团级报表/树形 UI,可能需要 PAC 侧缓存名。→ 倾向:结构 PAC 拥有,名走字典。**待你定。**
2. **子表要不要冗余 brand 列**?查询过滤性能 vs 冗余。→ 倾向:`patient_facts`/`followup_plans` 冗余 brand,省 join;其余不加。 2. **子表要不要冗余 source_unit 列**?查询过滤性能 vs 冗余。→ 倾向:`patient_facts`/`followup_plans` 冗余 source_unit,省 join;其余不加。**待你定。**
3. **多集团 host**(host:集团 = 1:N)现在要不要支持,还是先锁 1:1?→ 倾向:schema 留得住(tenant_id=集团已支持多值),代码先按 1:1 实现。 3. ~~多集团 host 先锁 1:1?~~ **✅ 已定:支持多集团**,schema 本就支持(tenant_id=集团多值),代码直接按 1:N 实现。
4. **集团 UUID 谁定**?用现瑞尔 tenant `ba67e6cf` 顶上当集团,还是新发一个?→ 倾向:新发集团 UUID,避免与品牌 UUID 语义混淆 4. **集团 UUID 谁定**?**✅ 已定:新发**集团 UUID,不复用品牌 UUID;品牌→集团归属在 manifest `tenant_map` 配置
5. **纵深防御选 Prisma 中间件还是 RLS**?→ 倾向:先中间件,RLS 后续硬化 5. ~~纵深防御选哪个?~~ **✅ 已定:Prisma `$extends` + AsyncLocalStorage**,RLS 作 P2+ 硬化(见 §6.3)
--- ---
...@@ -275,12 +306,12 @@ P0+P1 即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌 ...@@ -275,12 +306,12 @@ P0+P1 即满足"租户=集团、集团数据隔离(跟原 SaaS 一致)、品牌
| 文件 | 改动 | | 文件 | 改动 |
|---|---| |---|---|
| `prisma/schema.prisma` | `patients.brand` + 唯一键;新增 `model OrgUnit`;子表 tenant 语义 | | `prisma/schema.prisma` | `patients.source_unit` + 唯一键;新增 `model OrgUnit`;子表 tenant 语义 |
| `prisma/migrations/*` | 加列/改键/重映射/建 org_units 的迁移 SQL | | `prisma/migrations/*` | 加列/改键/重映射/建 org_units 的迁移 SQL |
| `data/jvs-dw/manifest.yaml` | `tenant_field:brand``tenant_id:<group>` + `brand_field:brand` | | `data/jvs-dw/manifest.yaml` | `tenant_map` 改成 brand→集团UUID + 新增 `identity_namespace_field: brand` |
| `sync/cold-import/manifest.schema.ts` | 新增 `brand_field` | | `sync/cold-import/manifest.schema.ts` | 新增 `identity_namespace_field` |
| `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert where 加 brand | | `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert where 加 source_unit |
| `sync/cold-import/cold-import.service.ts:1314/1494/1896` | upsert where 加 brand | | `sync/cold-import/cold-import.service.ts:1314/1494/1896` | upsert where 加 source_unit |
| `packages/types/src/schemas/auth.ts` | token 加 `orgUnitSubtree`/`brandSharingPolicy` | | `packages/types/src/schemas/auth.ts` | token 加 `orgUnitSubtree`/`brandSharingPolicy` |
| `common/decorators/tenant-scope.decorator.ts` | scope 扩形 | | `common/decorators/tenant-scope.decorator.ts` | scope 扩形 |
| `common/interceptors/tenant-scope.interceptor.ts` | 填新 scope 字段 | | `common/interceptors/tenant-scope.interceptor.ts` | 填新 scope 字段 |
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment