Commit 0bf6a25c by luoqi

fix(auth): 数据权限支持契约品牌 GUID —— org 树品牌节点认别名,修复品牌主管 orgScope 空数据

契约约定 orgScope 传宿主组织节点 id(品牌级=品牌 GUID),但 PAC org 树品牌节点
id 用的是名字(source_unit=瑞尔/瑞泰),宿主传品牌 GUID(ba67e6cf…=瑞尔)对不上
→ 品牌制租户 fail-closed → 品牌主管(如岳丽丽 leader)登录后一条数据看不到。
诊所层无此问题(节点 id 本就是诊所 GUID)。

改动:
- OrgNode 加 aliases[];expandOrgScope 按 id+aliases 建索引(名字/GUID 等价命中)
- buildOrgTree 接受 brandAliases(品牌名→GUID),挂到品牌节点 aliases
- Host 加 org_aliases(Json):PAC 节点 id→宿主契约 id 映射;deriveTree 读它传入
- jvs-dw-preset 补品牌 GUID;seed 回填 jvs-dw.org_aliases(瑞尔=ba67e6cf/瑞泰=77057aed)
- 模拟登录品牌级改传品牌 GUID(真正模拟契约,旧版传品牌名自洽掩盖了此 bug)
不动 source_unit、不影响诊所层与 friday(source_unit 本就是 GUID)。

本地验证:真实 OrgTreeService.expandScope(品牌 GUID ba67e6cf) → sourceUnits=[瑞尔]
+ 瑞尔全部诊所;品牌名/诊所 GUID 照常;typecheck 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 7235b228
Pipeline #3346 failed in 0 seconds
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "org_aliases" JSONB NOT NULL DEFAULT '{}';
...@@ -115,6 +115,13 @@ model Host { ...@@ -115,6 +115,13 @@ model Host {
/// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮 /// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮
actionUrls Json @default("{}") @map("action_urls") actionUrls Json @default("{}") @map("action_urls")
/// 组织节点别名:PAC 内部 org 树节点 id 宿主契约里的组织 id(orgScope 传的那套)
/// 用于"PAC 节点 id ≠ 宿主契约 id"的层 —— 典型:品牌层 PAC 用名字(source_unit,"瑞尔"),
/// 宿主 orgScope 按契约传品牌 GUID(`ba67e6cf`)。挂进 org 树品牌节点 aliases,
/// 让品牌名 / 品牌 GUID 两种传法都能命中,不改 source_unit、不影响诊所层。
/// 形状: { "瑞尔": "ba67e6cf30dc4f9c9c46adef188bbd04", "瑞泰": "77057aed269f4a14957ae0ad0eff359a" }
orgAliases Json @default("{}") @map("org_aliases")
/// 执行结果回执地址(宿主接收 POST HTTPS endpoint);null = 不启用回执(opt-in) /// 执行结果回执地址(宿主接收 POST HTTPS endpoint);null = 不启用回执(opt-in)
callbackUrl String? @map("callback_url") callbackUrl String? @map("callback_url")
/// 回执验签用的 HMAC 共享密钥;PAC **签名**,故存**原文**(不同于 appSecret 存哈希) /// 回执验签用的 HMAC 共享密钥;PAC **签名**,故存**原文**(不同于 appSecret 存哈希)
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
*/ */
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { hashSecret } from '@pac/utils'; import { hashSecret } from '@pac/utils';
import { JVS_DW_BRAND_ALIASES } from '../src/modules/auth/jvs-dw-preset';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
...@@ -22,7 +23,8 @@ async function main() { ...@@ -22,7 +23,8 @@ async function main() {
const jvsDw = await prisma.host.upsert({ const jvsDw = await prisma.host.upsert({
where: { appId: 'jvs-dw-app' }, where: { appId: 'jvs-dw-app' },
update: {}, // 品牌名→品牌 GUID 别名:让契约 orgScope(品牌 GUID)命中 org 树品牌节点(重跑 seed 也回填)
update: { orgAliases: JVS_DW_BRAND_ALIASES },
create: { create: {
name: 'jvs-dw', name: 'jvs-dw',
appId: 'jvs-dw-app', appId: 'jvs-dw-app',
...@@ -36,6 +38,7 @@ async function main() { ...@@ -36,6 +38,7 @@ async function main() {
}, },
pushSecretHashes: [hashSecret(jvsDwPushSecret)], pushSecretHashes: [hashSecret(jvsDwPushSecret)],
actionUrls: {}, actionUrls: {},
orgAliases: JVS_DW_BRAND_ALIASES,
active: true, active: true,
}, },
}); });
......
...@@ -312,10 +312,12 @@ export class AuthService { ...@@ -312,10 +312,12 @@ export class AuthService {
sub = cs.externalId; sub = cs.externalId;
subName = cs.name; subName = cs.name;
const own = cs.clinics.map((c) => c.clinicId).filter((id) => id in preset.clinics); const own = cs.clinics.map((c) => c.clinicId).filter((id) => id in preset.clinics);
orgScope = own.length > 0 ? own : [preset.sourceUnit]; // 无诊所兜底回品牌级 orgScope = own.length > 0 ? own : [preset.brandOrgId]; // 无诊所兜底回品牌级(品牌 GUID)
} else { } else {
// ── 品牌级:整个品牌(全部诊所)── // ── 品牌级:整个品牌(全部诊所)──
orgScope = [preset.sourceUnit]; // 用品牌 GUID(契约=传 id),真正模拟真实宿主换票;org 树靠 host.orgAliases 认它。
// (旧版本传品牌名"瑞尔",与 PAC 内部树自洽却测不出"宿主传 GUID"这类 bug。)
orgScope = [preset.brandOrgId];
} }
const dictionary: TokenDictionary = { const dictionary: TokenDictionary = {
......
...@@ -13,6 +13,9 @@ export const JVS_DW_PRESETS = { ...@@ -13,6 +13,9 @@ export const JVS_DW_PRESETS = {
ruier: { ruier: {
tenantId: 'ruier-grp', // 集团(= 租户) tenantId: 'ruier-grp', // 集团(= 租户)
sourceUnit: '瑞尔', // 品牌(= patients.source_unit) sourceUnit: '瑞尔', // 品牌(= patients.source_unit)
/// 品牌 GUID(源 DW tenant_id)—— 真实宿主 orgScope 品牌级传的就是它(契约=传 id)。
/// 模拟登录品牌级用它,才是真正"模拟 host 契约";org 树靠 host.orgAliases 认它。
brandOrgId: 'ba67e6cf30dc4f9c9c46adef188bbd04',
tenantNameZh: '瑞尔', tenantNameZh: '瑞尔',
clinics: { clinics: {
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所', '7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
...@@ -23,6 +26,7 @@ export const JVS_DW_PRESETS = { ...@@ -23,6 +26,7 @@ export const JVS_DW_PRESETS = {
ruitai: { ruitai: {
tenantId: 'ruier-grp', // 同集团 tenantId: 'ruier-grp', // 同集团
sourceUnit: '瑞泰', sourceUnit: '瑞泰',
brandOrgId: '77057aed269f4a14957ae0ad0eff359a',
tenantNameZh: '瑞泰', tenantNameZh: '瑞泰',
clinics: { clinics: {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院', c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
...@@ -30,3 +34,8 @@ export const JVS_DW_PRESETS = { ...@@ -30,3 +34,8 @@ export const JVS_DW_PRESETS = {
}, },
}, },
} as const; } as const;
/// 品牌名 → 品牌 GUID(= host.orgAliases 应写入 jvs-dw 的值)。seed / 回填用。
export const JVS_DW_BRAND_ALIASES: Record<string, string> = Object.fromEntries(
Object.values(JVS_DW_PRESETS).map((p) => [p.sourceUnit, p.brandOrgId]),
);
...@@ -25,6 +25,12 @@ export interface OrgNode { ...@@ -25,6 +25,12 @@ export interface OrgNode {
* (jvs-dw = 品牌;别的 host 按诊所发号则诊所节点带)。区域/集团等中间层不带。 * (jvs-dw = 品牌;别的 host 按诊所发号则诊所节点带)。区域/集团等中间层不带。
*/ */
sourceUnit?: string; sourceUnit?: string;
/**
* 额外可命中的 scope ref(与 id 等价匹配)。用于"PAC 内部节点 id ≠ 宿主契约 id"的层:
* 如品牌层 PAC 用名字("瑞尔")当 id,但宿主 orgScope 按契约传品牌 GUID
* (`ba67e6cf…`)—— 把 GUID 挂这里,名字 / GUID 两种传法都能命中,不动 source_unit。
*/
aliases?: string[];
/** 诊所 id。只有叶子诊所节点带。 */ /** 诊所 id。只有叶子诊所节点带。 */
clinicId?: string; clinicId?: string;
/** 子节点。无限嵌套。 */ /** 子节点。无限嵌套。 */
...@@ -47,11 +53,14 @@ export interface OrgTree { ...@@ -47,11 +53,14 @@ export interface OrgTree {
export function buildOrgTree( export function buildOrgTree(
groupId: string, groupId: string,
brandClinics: Record<string, string[]>, brandClinics: Record<string, string[]>,
/** 品牌名 → 宿主品牌 GUID(可选)。挂到品牌节点 aliases,让契约 orgScope(品牌 GUID)也能命中。 */
brandAliases?: Record<string, string>,
): OrgTree { ): OrgTree {
const root: OrgNode = { const root: OrgNode = {
id: groupId, id: groupId,
children: Object.entries(brandClinics).map(([brand, clinics]) => ({ children: Object.entries(brandClinics).map(([brand, clinics]) => ({
id: brand, id: brand,
...(brandAliases?.[brand] ? { aliases: [brandAliases[brand]] } : {}),
sourceUnit: brand, sourceUnit: brand,
children: clinics.map((c) => ({ id: c, clinicId: c })), children: clinics.map((c) => ({ id: c, clinicId: c })),
})), })),
...@@ -123,7 +132,10 @@ export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandResult { ...@@ -123,7 +132,10 @@ export function expandOrgScope(tree: OrgTree, refs: string[]): ExpandResult {
(function walk(n: OrgNode, inheritedSu: string | undefined) { (function walk(n: OrgNode, inheritedSu: string | undefined) {
const su = n.sourceUnit ?? inheritedSu; const su = n.sourceUnit ?? inheritedSu;
if (n.sourceUnit) branded = true; if (n.sourceUnit) branded = true;
(index.get(n.id) ?? index.set(n.id, []).get(n.id)!).push({ node: n, su }); // id + aliases 都建索引(别名与 id 等价命中,如品牌 GUID)
for (const key of [n.id, ...(n.aliases ?? [])]) {
(index.get(key) ?? index.set(key, []).get(key)!).push({ node: n, su });
}
for (const c of n.children ?? []) walk(c, su); for (const c of n.children ?? []) walk(c, su);
})(tree.root, undefined); })(tree.root, undefined);
......
...@@ -164,8 +164,16 @@ export class OrgTreeService implements OnApplicationBootstrap { ...@@ -164,8 +164,16 @@ export class OrgTreeService implements OnApplicationBootstrap {
for (const r of rows) { for (const r of rows) {
(brandClinics[r.source_unit] ??= []).push(r.clinic_id); (brandClinics[r.source_unit] ??= []).push(r.clinic_id);
} }
// 品牌名 → 宿主品牌 GUID 别名(host 配置)。让契约 orgScope(品牌 GUID)也能命中品牌节点。
const host = await this.prisma.host.findUnique({
where: { id: hostId },
select: { orgAliases: true },
});
const brandAliases = (host?.orgAliases ?? undefined) as
| Record<string, string>
| undefined;
return Object.keys(brandClinics).length > 0 return Object.keys(brandClinics).length > 0
? buildOrgTree(tenantId, brandClinics) ? buildOrgTree(tenantId, brandClinics, brandAliases)
: buildTree({ id: tenantId }); // 无数据 → 退化树 : buildTree({ id: tenantId }); // 无数据 → 退化树
} }
......
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