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 { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { JVS_DW_ORG_TREE, expandOrgScope } from './org-scope'; import { buildOrgTree, expandOrgScope, type OrgTree } from './org-scope';
import { import {
ApiCode, ApiCode,
ROLE_PERMISSIONS, ROLE_PERMISSIONS,
...@@ -80,7 +80,7 @@ export class AuthService { ...@@ -80,7 +80,7 @@ export class AuthService {
const tenantId = req.user.tenantId; const tenantId = req.user.tenantId;
// 标准数据权限:host 传 orgScope(任意层级 org 节点)→ 按 host org 树展开成 sourceUnits + clinicIds; // 标准数据权限:host 传 orgScope(任意层级 org 节点)→ 按 host org 树展开成 sourceUnits + clinicIds;
// 未传 orgScope 则用显式 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 = const expanded =
req.user.orgScope && orgTree ? expandOrgScope(orgTree, req.user.orgScope) : null; req.user.orgScope && orgTree ? expandOrgScope(orgTree, req.user.orgScope) : null;
const sourceUnits = expanded ? expanded.sourceUnits : (req.user.sourceUnits ?? []); const sourceUnits = expanded ? expanded.sourceUnits : (req.user.sourceUnits ?? []);
...@@ -272,6 +272,15 @@ export class AuthService { ...@@ -272,6 +272,15 @@ export class AuthService {
return (this.config.get<string>('nodeEnv') ?? process.env.NODE_ENV) !== 'production'; 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> { async mockLogin(req: MockLoginRequest): Promise<ExchangeCodeResponse> {
if (!this.isMockLoginEnabled()) { if (!this.isMockLoginEnabled()) {
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用'); throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用');
...@@ -290,13 +299,21 @@ export class AuthService { ...@@ -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 sub = `mock-${req.tenant}-${req.role}`;
let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role); let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role);
let clinicIds = Object.keys(preset.clinics); let orgScope: string[];
// 全集团诊所(集团级登录 / 字典用)
// 选了具体客服 → 用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所) const allClinics = { ...this.MOCK_PRESETS.ruier.clinics, ...this.MOCK_PRESETS.ruitai.clinics };
if (req.userExternalId) {
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( const cs = loadMockUsers().find(
(u) => u.tenant === req.tenant && u.externalId === req.userExternalId, (u) => u.tenant === req.tenant && u.externalId === req.userExternalId,
); );
...@@ -308,14 +325,19 @@ export class AuthService { ...@@ -308,14 +325,19 @@ export class AuthService {
} }
sub = cs.externalId; sub = cs.externalId;
subName = cs.name; subName = cs.name;
// 只保留落在该 tenant 预制诊所内的(防脏数据);为空兜底回全 tenant 诊所
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);
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 = { const dictionary: TokenDictionary = {
clinics: preset.clinics as Record<string, string>, clinics: (req.orgLevel === 'group' ? allClinics : preset.clinics) as Record<string, string>,
// 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用)
users: { [sub]: subName }, users: { [sub]: subName },
}; };
const permissions = this.resolvePermissions(req.role); const permissions = this.resolvePermissions(req.role);
...@@ -325,7 +347,7 @@ export class AuthService { ...@@ -325,7 +347,7 @@ export class AuthService {
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
sourceUnits: [preset.sourceUnit], // 数据范围按本品牌圈(集团内品牌隔离,复刻现状) sourceUnits, // org-scope 展开:诊所/品牌→[品牌];集团→[](不限)
role: req.role, role: req.role,
permissions, permissions,
dictionary, dictionary,
...@@ -335,7 +357,7 @@ export class AuthService { ...@@ -335,7 +357,7 @@ export class AuthService {
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
sourceUnits: [preset.sourceUnit], sourceUnits,
role: req.role, role: req.role,
dictionary, dictionary,
}); });
......
...@@ -17,25 +17,18 @@ export interface OrgTree { ...@@ -17,25 +17,18 @@ export interface OrgTree {
brands: Record<string, { clinics: string[] }>; brands: Record<string, { clinics: string[] }>;
} }
/** jvs-dw 组织树(集团 ruier-grp → 瑞尔/瑞泰 → 诊所)。诊所 id 同 auth MOCK_PRESETS。 */ /**
export const JVS_DW_ORG_TREE: OrgTree = { * 从「品牌 → 诊所 id 列表」构建组织树。**不 hardcode** —— 调用方从已有配置/数据派生:
groupId: 'ruier-grp', * - mock/contract:从 MOCK_PRESETS(品牌→诊所)派生;
brands: { * - 通用态:从摄入数据派生(clinic→brand = patient_transactions.clinic_id JOIN patients.source_unit)。
瑞尔: { * 实现对所有 host 通用,差异只在「树数据」,而树数据 3 层(集团/品牌/诊所)本就在数据里。
clinics: [ * 仅「区域」等数据外的额外层才需 manifest yaml 声明。
'7d49539c7573490387c03e6496ff1a6c', // 杭州大厦 */
'dad2f04a120947e2b82b41cbd108f3f4', // 杭州高德 export function buildOrgTree(groupId: string, brandClinics: Record<string, string[]>): OrgTree {
'66701845dd2342e19f9e9f576c4ffe9c', // 北京朝阳公园 const brands: OrgTree['brands'] = {};
], for (const [brand, clinics] of Object.entries(brandClinics)) brands[brand] = { clinics };
}, return { groupId, brands };
瑞泰: { }
clinics: [
'c18cadf2d3cd4adda5527debd41356eb', // 通善口腔学前街
'e83d432a38bb4f6284713b36db4e7497', // 上海世纪公园
],
},
},
};
export interface ExpandedScope { export interface ExpandedScope {
/** 患者归属层范围(品牌)。空数组 = 不限(集团级,看全集团所有品牌) */ /** 患者归属层范围(品牌)。空数组 = 不限(集团级,看全集团所有品牌) */
......
...@@ -88,7 +88,7 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -88,7 +88,7 @@ export function MockLoginDialog({ open }: { open: boolean }) {
} }
}; };
// 降级路径:通用预制身份(无名册时) // 降级路径:通用预制身份(无名册时)= 品牌级(整个 tenant 品牌全部诊所)
const pickGeneric = async (tenant: BrandSlug, roleKey: UserRole) => { const pickGeneric = async (tenant: BrandSlug, roleKey: UserRole) => {
const key = `${tenant}:${roleKey}`; const key = `${tenant}:${roleKey}`;
if (busy) return; if (busy) return;
...@@ -96,7 +96,21 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -96,7 +96,21 @@ export function MockLoginDialog({ open }: { open: boolean }) {
try { try {
const r = await mockLogin({ tenant, role: roleKey }); const r = await mockLogin({ tenant, role: roleKey });
applyTokens(r); 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) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
toast.error('登录失败', { description: msg.slice(0, 120) }); toast.error('登录失败', { description: msg.slice(0, 120) });
...@@ -155,6 +169,15 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -155,6 +169,15 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</button> </button>
))} ))}
</div> </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> </div>
{/* ② 选客服 */} {/* ② 选客服 */}
......
...@@ -128,6 +128,9 @@ export const MockLoginRequestSchema = z.strictObject({ ...@@ -128,6 +128,9 @@ export const MockLoginRequestSchema = z.strictObject({
/// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。 /// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。
/// 给了则用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所);不给走通用预制身份。 /// 给了则用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所);不给走通用预制身份。
userExternalId: z.string().optional(), userExternalId: z.string().optional(),
/// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。
/// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。
orgLevel: z.enum(['group']).optional(),
}); });
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>; 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