Commit 44539c27 by luoqi

feat(auth): org 树从配置派生(去 hardcode)+ 模拟登录支持集团/品牌/诊所三级

- org-scope:删 JVS_DW_ORG_TREE hardcode,改 buildOrgTree(从配置/数据派生);
  实现对所有 host 通用,树数据 3 层本就在数据里(yaml 只为区域等数据外的层)
- auth:jvsDwOrgTree() 从 MOCK_PRESETS 派生;exchangeToken + mockLogin 都走 expandOrgScope
- mockLogin:数据范围统一 org-scope —— 诊所级(选客服)/品牌级(仅品牌)/集团级(orgLevel=group,
  sourceUnits=[] 看全部品牌全部诊所);MockLoginRequest 加 orgLevel
- 前端:加"以集团身份登录"按钮(跨品牌,验证数据权限最上一层)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent db2c0874
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 { buildOrgTree, expandOrgScope, type OrgTree } from './org-scope';
import {
ApiCode,
ROLE_PERMISSIONS,
......@@ -80,7 +80,7 @@ export class AuthService {
const tenantId = req.user.tenantId;
// 标准数据权限:host 传 orgScope(任意层级 org 节点)→ 按 host org 树展开成 sourceUnits + clinicIds;
// 未传 orgScope 则用显式 sourceUnits/clinicIds(向后兼容)。
const orgTree = host.name === 'jvs-dw' ? JVS_DW_ORG_TREE : null;
const orgTree = host.name === 'jvs-dw' ? this.jvsDwOrgTree() : null;
const expanded =
req.user.orgScope && orgTree ? expandOrgScope(orgTree, req.user.orgScope) : null;
const sourceUnits = expanded ? expanded.sourceUnits : (req.user.sourceUnits ?? []);
......@@ -272,6 +272,15 @@ export class AuthService {
return (this.config.get<string>('nodeEnv') ?? process.env.NODE_ENV) !== 'production';
}
/** jvs-dw 组织树 —— 从 MOCK_PRESETS(品牌→诊所)派生,不 hardcode。
* 通用态:本应从摄入数据派生(clinic→brand),实现对所有 host 一致。 */
private jvsDwOrgTree(): OrgTree {
return buildOrgTree('ruier-grp', {
瑞尔: Object.keys(this.MOCK_PRESETS.ruier.clinics),
瑞泰: Object.keys(this.MOCK_PRESETS.ruitai.clinics),
});
}
async mockLogin(req: MockLoginRequest): Promise<ExchangeCodeResponse> {
if (!this.isMockLoginEnabled()) {
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用');
......@@ -290,13 +299,21 @@ export class AuthService {
);
}
// 默认:通用预制身份(sub=mock-<tenant>-<role>,看该 tenant 全部诊所)
// 集团模型:数据范围统一走 org-scope(任意层级)→ expandOrgScope 展开成 sourceUnits + clinicIds。
const tree = this.jvsDwOrgTree();
let sub = `mock-${req.tenant}-${req.role}`;
let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role);
let clinicIds = Object.keys(preset.clinics);
// 选了具体客服 → 用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所)
if (req.userExternalId) {
let orgScope: string[];
// 全集团诊所(集团级登录 / 字典用)
const allClinics = { ...this.MOCK_PRESETS.ruier.clinics, ...this.MOCK_PRESETS.ruitai.clinics };
if (req.orgLevel === 'group') {
// ── 集团级:看全部品牌全部诊所(sourceUnits=[] 不限)──
sub = `mock-group-${req.role}`;
subName = `集团${roleNameZh(req.role)}`;
orgScope = [tree.groupId];
} else if (req.userExternalId) {
// ── 诊所级:具体客服真实身份 + 其所属诊所 ──
const cs = loadMockUsers().find(
(u) => u.tenant === req.tenant && u.externalId === req.userExternalId,
);
......@@ -308,14 +325,19 @@ export class AuthService {
}
sub = cs.externalId;
subName = cs.name;
// 只保留落在该 tenant 预制诊所内的(防脏数据);为空兜底回全 tenant 诊所
const own = cs.clinics.map((c) => c.clinicId).filter((id) => id in preset.clinics);
clinicIds = own.length > 0 ? own : Object.keys(preset.clinics);
orgScope = own.length > 0 ? own : [preset.sourceUnit]; // 无诊所兜底回品牌级
} else {
// ── 品牌级:整个品牌(全部诊所)──
orgScope = [preset.sourceUnit];
}
const expanded = expandOrgScope(tree, orgScope);
const sourceUnits = expanded.sourceUnits;
const clinicIds = expanded.clinicIds; // 空 = 不限(集团级)
const dictionary: TokenDictionary = {
clinics: preset.clinics as Record<string, string>,
// 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用)
clinics: (req.orgLevel === 'group' ? allClinics : preset.clinics) as Record<string, string>,
users: { [sub]: subName },
};
const permissions = this.resolvePermissions(req.role);
......@@ -325,7 +347,7 @@ export class AuthService {
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
sourceUnits: [preset.sourceUnit], // 数据范围按本品牌圈(集团内品牌隔离,复刻现状)
sourceUnits, // org-scope 展开:诊所/品牌→[品牌];集团→[](不限)
role: req.role,
permissions,
dictionary,
......@@ -335,7 +357,7 @@ export class AuthService {
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
sourceUnits: [preset.sourceUnit],
sourceUnits,
role: req.role,
dictionary,
});
......
......@@ -17,25 +17,18 @@ export interface OrgTree {
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', // 上海世纪公园
],
},
},
};
/**
* 从「品牌 → 诊所 id 列表」构建组织树。**不 hardcode** —— 调用方从已有配置/数据派生:
* - mock/contract:从 MOCK_PRESETS(品牌→诊所)派生;
* - 通用态:从摄入数据派生(clinic→brand = patient_transactions.clinic_id JOIN patients.source_unit)。
* 实现对所有 host 通用,差异只在「树数据」,而树数据 3 层(集团/品牌/诊所)本就在数据里。
* 仅「区域」等数据外的额外层才需 manifest yaml 声明。
*/
export function buildOrgTree(groupId: string, brandClinics: Record<string, string[]>): OrgTree {
const brands: OrgTree['brands'] = {};
for (const [brand, clinics] of Object.entries(brandClinics)) brands[brand] = { clinics };
return { groupId, brands };
}
export interface ExpandedScope {
/** 患者归属层范围(品牌)。空数组 = 不限(集团级,看全集团所有品牌) */
......
......@@ -88,7 +88,7 @@ export function MockLoginDialog({ open }: { open: boolean }) {
}
};
// 降级路径:通用预制身份(无名册时)
// 降级路径:通用预制身份(无名册时)= 品牌级(整个 tenant 品牌全部诊所)
const pickGeneric = async (tenant: BrandSlug, roleKey: UserRole) => {
const key = `${tenant}:${roleKey}`;
if (busy) return;
......@@ -96,7 +96,21 @@ export function MockLoginDialog({ open }: { open: boolean }) {
try {
const r = await mockLogin({ tenant, role: roleKey });
applyTokens(r);
// 登录成功不弹 toast —— 直接进入应用即反馈
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error('登录失败', { description: msg.slice(0, 120) });
setBusy(null);
}
};
// 集团级登录:看全部品牌全部诊所(sourceUnits=[] 不限)— 验证数据权限最上一层
const pickGroup = async (roleKey: UserRole) => {
const key = `group:${roleKey}`;
if (busy) return;
setBusy(key);
try {
const r = await mockLogin({ tenant: 'ruier', role: roleKey, orgLevel: 'group' });
applyTokens(r);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error('登录失败', { description: msg.slice(0, 120) });
......@@ -155,6 +169,15 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</button>
))}
</div>
{/* 集团级登录:数据范围 = 全部品牌全部诊所(sourceUnits=[] 不限)*/}
<button
type="button"
onClick={() => pickGroup(role)}
disabled={!!busy}
className="mt-2 w-full rounded-md border border-dashed border-amber-300 bg-amber-50/40 px-2.5 py-1.5 text-left text-[11.5px] text-amber-800 transition-colors hover:border-amber-400 disabled:opacity-50"
>
以「集团 · {roleNameZh(role)}」身份登录 —— 看<strong>全部品牌全部诊所</strong>(跨品牌,数据权限最上一层)
</button>
</div>
{/* ② 选客服 */}
......
......@@ -128,6 +128,9 @@ export const MockLoginRequestSchema = z.strictObject({
/// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。
/// 给了则用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所);不给走通用预制身份。
userExternalId: z.string().optional(),
/// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。
/// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。
orgLevel: z.enum(['group']).optional(),
});
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
......
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