Commit db2c0874 by luoqi

feat(auth): 标准数据权限 org-scope(内存树展开,不建表)

数据权限 = 用户的 org 范围(任意层级:集团/品牌/诊所)。expandOrgScope() 把
org 节点展开成 sourceUnits + clinicIds(已有过滤层不变):
  诊所 ref → 该诊所 + 所属品牌;品牌 ref → 该品牌 + 其下全部诊所;
  集团 ref / 空 → 不限。多 ref 取并集。
- org-scope.ts:jvs-dw 内存树(ruier-grp→瑞尔/瑞泰→诊所)+ expandOrgScope
- 契约 exchangeToken 接 orgScope(优先于显式 sourceUnits/clinicIds)→ 展开
- TokenExchangeUserSchema 加 orgScope
验证:诊所/品牌/集团/跨品牌四级展开均正确。通用态树放 manifest yaml。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent a6d7eb22
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { JVS_DW_ORG_TREE, expandOrgScope } from './org-scope';
import {
ApiCode,
ROLE_PERMISSIONS,
......@@ -77,7 +78,14 @@ export class AuthService {
}
const tenantId = req.user.tenantId;
const clinicIds = req.user.clinicIds.length > 0 ? req.user.clinicIds : ['default'];
// 标准数据权限:host 传 orgScope(任意层级 org 节点)→ 按 host org 树展开成 sourceUnits + clinicIds;
// 未传 orgScope 则用显式 sourceUnits/clinicIds(向后兼容)。
const orgTree = host.name === 'jvs-dw' ? JVS_DW_ORG_TREE : null;
const expanded =
req.user.orgScope && orgTree ? expandOrgScope(orgTree, req.user.orgScope) : null;
const sourceUnits = expanded ? expanded.sourceUnits : (req.user.sourceUnits ?? []);
const rawClinicIds = expanded ? expanded.clinicIds : req.user.clinicIds;
const clinicIds = rawClinicIds.length > 0 ? rawClinicIds : ['default'];
const permissions = this.resolvePermissions(req.user.role);
const accessToken = await this.signAccessToken({
......@@ -85,7 +93,7 @@ export class AuthService {
hostId: host.id,
tenantId,
clinicIds,
sourceUnits: req.user.sourceUnits ?? [], // 集团模型:品牌数据范围(缺省=不限)
sourceUnits, // 集团模型:品牌数据范围(orgScope 展开 或 显式;空=不限)
role: req.user.role,
permissions,
// 可选名字典(clinics/users id→名)透传进 JWT,前端用于 UI 显示
......@@ -97,7 +105,7 @@ export class AuthService {
hostId: host.id,
tenantId,
clinicIds,
sourceUnits: req.user.sourceUnits ?? [],
sourceUnits,
role: req.user.role,
dictionary: req.user.dictionary,
});
......
/**
* 组织范围(标准数据权限)—— 不建表,内存树 + 登录时展开。
*
* 数据权限 = 用户的「org 范围」(一组组织节点,任意层级:集团/品牌/诊所)。
* 登录时 expandOrgScope() 把 org 范围**展开**成两个过滤维度,塞进 token:
* - sourceUnits:范围覆盖的「患者归属层」(jvs-dw = 品牌)→ 过滤患者粒度
* - clinicIds:范围覆盖的诊所 → 过滤 plan(target_clinic_id)
* 过滤层(patient.sourceUnit / plan.targetClinicId)已实现,这里只做"范围→两维"。
*
* 树本身是 host 配置(通用态放 manifest yaml org_tree;此处先内置 jvs-dw)。
* source_unit/品牌只是树上一层 —— 别的 host 患者按诊所发号时,归属层就是诊所,同套机制。
*/
export interface OrgTree {
groupId: string;
/** 品牌(= patients.source_unit)→ 其下诊所 id */
brands: Record<string, { clinics: string[] }>;
}
/** jvs-dw 组织树(集团 ruier-grp → 瑞尔/瑞泰 → 诊所)。诊所 id 同 auth MOCK_PRESETS。 */
export const JVS_DW_ORG_TREE: OrgTree = {
groupId: 'ruier-grp',
brands: {
瑞尔: {
clinics: [
'7d49539c7573490387c03e6496ff1a6c', // 杭州大厦
'dad2f04a120947e2b82b41cbd108f3f4', // 杭州高德
'66701845dd2342e19f9e9f576c4ffe9c', // 北京朝阳公园
],
},
瑞泰: {
clinics: [
'c18cadf2d3cd4adda5527debd41356eb', // 通善口腔学前街
'e83d432a38bb4f6284713b36db4e7497', // 上海世纪公园
],
},
},
};
export interface ExpandedScope {
/** 患者归属层范围(品牌)。空数组 = 不限(集团级,看全集团所有品牌) */
sourceUnits: string[];
/** 诊所范围。空数组 = 不限(集团级,看全集团所有诊所) */
clinicIds: string[];
}
/**
* 把一组 org 节点 ref 展开成 { sourceUnits, clinicIds }。
* ref 可以是任意层级:
* - 集团 id(groupId)→ 不限(sourceUnits=[], clinicIds=[]):看全集团
* - 品牌名(source_unit,如 '瑞尔')→ 该品牌 + 其下全部诊所
* - 诊所 id → 该诊所 + 其所属品牌
* 多个 ref 取并集。集团级一旦出现,即整体不限。
*/
export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandedScope {
if (refs.length === 0) return { sourceUnits: [], clinicIds: [] }; // 空范围 = 不限(集团级)
const clinicToBrand = new Map<string, string>();
for (const [brand, b] of Object.entries(tree.brands)) {
for (const c of b.clinics) clinicToBrand.set(c, brand);
}
const sourceUnits = new Set<string>();
const clinicIds = new Set<string>();
for (const ref of refs) {
if (ref === tree.groupId) {
return { sourceUnits: [], clinicIds: [] }; // 集团级 → 整体不限
}
const brand = tree.brands[ref];
if (brand) {
// 品牌级
sourceUnits.add(ref);
for (const c of brand.clinics) clinicIds.add(c);
continue;
}
const ownerBrand = clinicToBrand.get(ref);
if (ownerBrand) {
// 诊所级
sourceUnits.add(ownerBrand);
clinicIds.add(ref);
continue;
}
// 未知 ref:当诊所 id 透传(品牌未知则不加 sourceUnit,靠 clinic 圈)
clinicIds.add(ref);
}
return { sourceUnits: [...sourceUnits], clinicIds: [...clinicIds] };
}
......@@ -39,6 +39,13 @@ export const TokenExchangeUserSchema = z
.array(z.string())
.optional()
.describe('集团模型:用户可见品牌(source_unit)。空/缺省 = 不限品牌(集团级角色)'),
orgScope: z
.array(z.string())
.optional()
.describe(
'标准数据权限(可选,优先于 sourceUnits/clinicIds):用户可达的 org 节点(集团/品牌/诊所,任意层级)。' +
'PAC 按 host org 树展开成 sourceUnits + clinicIds。空 = 不限(集团级)',
),
dictionary: TokenDictionarySchema.optional().describe(
'Optional id→name dictionary for UI display (clinics / users)',
),
......
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