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 只消歧
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 集团内跨品牌归一(独立上层,本次不做)
## 4. Schema 改造
### 4.1 新增 `patients.brand` 列(命名空间限定符)
### 4.1 新增 `patients.source_unit` 列(通用命名空间限定符,**不写死 brand**)
> ⚠️ brand 不通用:别的 host 可能根本没"品牌",而是 集团→区域→诊所。
> 患者键里的消歧符应是**通用的"源组织命名空间"**,具体对应哪一层由 manifest 声明。
```prisma
model Patient {
// ...
brand String? @map("brand") // 品牌命名空间;NULL=单品牌 host
// 唯一键:加 brand
@@unique([hostId, tenantId, brand, externalId]) // 原 (hostId, tenantId, externalId)
sourceUnit String? @map("source_unit") // 源组织命名空间值(瑞尔=brand值;别的host=区域码/HIS实例…)
// 唯一键:加 source_unit
@@unique([hostId, tenantId, sourceUnit, externalId]) // 原 (hostId, tenantId, externalId)
}
```
- 回填:`brand` 从现 `tenant_id` 反查(`ba67e6cf→瑞尔``77057aed→瑞泰`)。
- 改键后再把 `tenant_id` 改成集团 UUID(顺序见 §8)。
- `source_unit` = manifest 指定的"身份命名空间字段"原值。jvs-dw 此字段 = `brand`(瑞尔/瑞泰)。
- 回填:`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` 组织树(本次唯一新实体表)
```prisma
......@@ -100,19 +107,27 @@ model OrgUnit {
hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id") // = 集团
parentId String? @map("parent_id") @db.Uuid // 自关联,树
nodeType String @map("node_type") // 'group'|'brand'|'region'|'clinic'
externalRef String? @map("external_ref") // 宿主侧 brand码 / clinic_id
unitType String @map("unit_type") // 'group'|'brand'|'region'|'clinic'… 字符串枚举
externalRef String? @map("external_ref") // 宿主侧 brand码 / 区域码 / clinic_id
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent OrgUnit? @relation("OrgTree", fields: [parentId], references: [id])
children OrgUnit[] @relation("OrgTree")
@@unique([hostId, tenantId, nodeType, externalRef])
@@index([hostId, tenantId, nodeType])
@@unique([hostId, tenantId, unitType, externalRef])
@@index([hostId, tenantId, unitType])
@@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 的"名字"放哪?**
> PAC 现行原则是「不立 tenants/clinics/users 主数据表,只存 id,展示名走宿主字典」([schema.prisma:18-20])。
> 建议沿用:`org_units` 只存**结构**(id/parent/type/external_ref),**不存 name**;name 仍由宿主登录字典(`dictionary.clinics`)解析。
......@@ -145,22 +160,36 @@ cohort:
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
tenant_id: <集团UUID> # 静态 → StaticTenantResolver,所有行 = 集团
brand_field: brand # 【新增】把 brand 当独立列捕获,落 patients.brand
tenant_field: brand # 复用 FieldMapTenantResolver,但 map 改成 →集团
tenant_map:
瑞尔: <集团UUID> # 两品牌都映到同一个新发集团
瑞泰: <集团UUID>
identity_namespace_field: brand # 【新增】把 brand 当独立命名空间捕获 → patients.source_unit
cohort:
patient_key_column: patient_id
tenant_key_column: brand # cohort 仍按 (patient_id, brand) 防撞号取数,不变
```
### 代码改动点
- `manifest.schema.ts`:新增可选 `brand_field`
- `tenant-resolver.ts`:不动(已支持静态)。
- **患者 upsert 落 brand**(4 处):
- `manifest.schema.ts`:新增可选 `identity_namespace_field`
- `tenant-resolver.ts`:**不动**(FieldMapTenantResolver 已能 brand→任意值,map 改成→集团即可)。
- 新增**命名空间解析**:按 `identity_namespace_field` 从行取 `source_unit` 值(与 tenant 路由解耦)。
- **患者 upsert 落 source_unit**(4 处):
- `pipeline/pipeline-dispatcher.service.ts:244`
- `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;
- **默认 closed**,确保上线即与现状行为一致、不误开放。
### 6.3 纵深防御(墙变软,必须补)
现状**零 Prisma 中间件 / 零 RLS**,隔离全靠手写 `where` 注入。tenant→集团后品牌过滤也是手写,**漏一处 = 跨品牌泄漏**。补一层兜底:
- 方案 A:`prisma.service.ts``$extends({ query })`,用 AsyncLocalStorage 注入当前集团,对 find*/count/groupBy 自动加 `tenantId` 过滤(漏写也兜得住)。
- 方案 B:Postgres RLS,`tenant_id` 行级策略。
- 建议先 A(应用层、改动小、可灰度),RLS 作为后续硬化。
现状**零 Prisma 中间件 / 零 RLS**,隔离全靠手写 `where` 注入。tenant→集团后品牌过滤也是手写,**漏一处 = 跨品牌泄漏**
**✅ 已定:Prisma `$extends` 客户端扩展 + AsyncLocalStorage 租户上下文为主,RLS 作为后续硬化(P2+)。**
- `prisma.service.ts``$extends({ query })`,从 ALS 取当前集团,对 find*/count/groupBy **自动注入 `tenantId` 过滤**(漏写也兜得住),并对未带租户上下文的查询告警。
- 理由:PAC 每服务单连接池,应用层兜底够;RLS 需按连接设 session 变量、与连接池配合麻烦、调试黑;中间件可增量加、无需 DB 迁移、能灰度。
- RLS 留作规模/合规要求上来后的二次硬化,不阻塞本次。
---
......@@ -235,16 +266,16 @@ interface TenantScopeContext {
> 现状本地:瑞尔 83,834 + 瑞泰 11,166 患者,472 撞号。线上同构。
1. **加列**:`patients.brand`(nullable),不动数据。
2. **回填 brand**:`UPDATE patients SET brand = CASE tenant_id WHEN 'ba67e6cf…' THEN '瑞尔' WHEN '77057aed…' THEN '瑞泰' END`
1. **加列**:`patients.source_unit`(nullable),不动数据。
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 需查)。
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(事务内,按表批量)。
6. **建 org_units**:从 distinct brand / clinic_id 派生节点(group 根 + brand + clinic)。
7. **切 manifest**:静态 group + `brand_field`
6. **建 org_units**:从 distinct source_unit / clinic_id 派生节点(group 根 + brand/region + clinic)。
7. **切 manifest**:`tenant_map`→集团 + `identity_namespace_field`
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 一致)、品牌
## 11. 待决策 / 开放问题
1. **org_units 存不存 name**?建议不存(沿用"宿主字典"原则),但若要做集团级报表/树形 UI,可能需要 PAC 侧缓存名。→ 倾向:结构 PAC 拥有,名走字典。
2. **子表要不要冗余 brand 列**?查询过滤性能 vs 冗余。→ 倾向:`patient_facts`/`followup_plans` 冗余 brand,省 join;其余不加。
3. **多集团 host**(host:集团 = 1:N)现在要不要支持,还是先锁 1:1?→ 倾向:schema 留得住(tenant_id=集团已支持多值),代码先按 1:1 实现。
4. **集团 UUID 谁定**?用现瑞尔 tenant `ba67e6cf` 顶上当集团,还是新发一个?→ 倾向:新发集团 UUID,避免与品牌 UUID 语义混淆
5. **纵深防御选 Prisma 中间件还是 RLS**?→ 倾向:先中间件,RLS 后续硬化
1. **org_units 存不存 name**?建议不存(沿用"宿主字典"原则),但若要做集团级报表/树形 UI,可能需要 PAC 侧缓存名。→ 倾向:结构 PAC 拥有,名走字典。**待你定。**
2. **子表要不要冗余 source_unit 列**?查询过滤性能 vs 冗余。→ 倾向:`patient_facts`/`followup_plans` 冗余 source_unit,省 join;其余不加。**待你定。**
3. ~~多集团 host 先锁 1:1?~~ **✅ 已定:支持多集团**,schema 本就支持(tenant_id=集团多值),代码直接按 1:N 实现。
4. **集团 UUID 谁定**?**✅ 已定:新发**集团 UUID,不复用品牌 UUID;品牌→集团归属在 manifest `tenant_map` 配置
5. ~~纵深防御选哪个?~~ **✅ 已定:Prisma `$extends` + AsyncLocalStorage**,RLS 作 P2+ 硬化(见 §6.3)
---
......@@ -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 |
| `data/jvs-dw/manifest.yaml` | `tenant_field:brand``tenant_id:<group>` + `brand_field:brand` |
| `sync/cold-import/manifest.schema.ts` | 新增 `brand_field` |
| `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert where 加 brand |
| `sync/cold-import/cold-import.service.ts:1314/1494/1896` | upsert where 加 brand |
| `data/jvs-dw/manifest.yaml` | `tenant_map` 改成 brand→集团UUID + 新增 `identity_namespace_field: brand` |
| `sync/cold-import/manifest.schema.ts` | 新增 `identity_namespace_field` |
| `sync/pipeline/pipeline-dispatcher.service.ts:244` | upsert where 加 source_unit |
| `sync/cold-import/cold-import.service.ts:1314/1494/1896` | upsert where 加 source_unit |
| `packages/types/src/schemas/auth.ts` | token 加 `orgUnitSubtree`/`brandSharingPolicy` |
| `common/decorators/tenant-scope.decorator.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