Commit 426bbfa4 by luoqi

feat(auth): 模拟登录按宿主分 tab;FRIDAY 数据范围用真实品牌/诊所

两宿主此前挤在一个面板(FRIDAY 只有一条集团级横条),数据概念互相干扰。改为宿主 tab:
- 瑞尔集团(jvs-dw):原客服花名册路径,一字未动
- FRIDAY 市场:身份是造的(该宿主本就无客服花名册),但**数据范围必须真实** ——
  品牌/诊所来自新端点 GET /auth/mock-orgs:结构取 org 树(派生自 patient_transactions),
  名字取 host.sourceUnitNames / clinicNames(派生自摄入源),零硬编码

- 契约:MockLoginRequest 加 orgId(任意 org 节点 = 品牌 GUID / 诊所 id);新增 MockOrgs 响应
- service:listMockOrgs(org 树 + 名字典,有名品牌与多诊所品牌排前);friday 分支支持
  orgScope=[orgId],不传则市场级
- web:FridayPanel(角色 + 市场级 + 真实品牌/诊所两级选择 + 搜索)

验证:FRIDAY tab 显示 19 品牌 / 35 诊所(瑞诚齿科 12 诊所、希尔德 3…);点"瑞诚齿科"
品牌级登录 → 右上角 12 个诊所、召回池由 11,723 收窄到 5 人、患者诊所均属该品牌 ✓;
瑞尔 tab 花名册与登录行为不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent 5ac83ffb
import { Body, Controller, Get, Post } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
...@@ -92,6 +92,14 @@ export class AuthController { ...@@ -92,6 +92,14 @@ export class AuthController {
return { users: this.auth.listMockUsers() }; return { users: this.auth.listMockUsers() };
} }
@Public()
@Get('mock-orgs')
@ApiOperation({ summary: '(dev) 真实组织结构(品牌→诊所),给快速登录选数据范围' })
async mockOrgs(@Query('host') host?: string, @Query('tenant') tenant?: string) {
// 默认 friday(多品牌宿主才需要选品牌/诊所);jvs-dw 走客服花名册路径
return this.auth.listMockOrgs(host || 'friday', tenant || 'friday-market');
}
@Get('session') @Get('session')
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@ZodResponse({ status: 200, type: SessionResponseDto }) @ZodResponse({ status: 200, type: SessionResponseDto })
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
type AccessTokenPayload, type AccessTokenPayload,
type ExchangeCodeResponse, type ExchangeCodeResponse,
type MockLoginRequest, type MockLoginRequest,
type MockOrgsResponse,
type MockUser, type MockUser,
type Permission, type Permission,
type RefreshTokenResponse, type RefreshTokenResponse,
...@@ -23,6 +24,7 @@ import { RedisService } from '../../redis/redis.service'; ...@@ -23,6 +24,7 @@ import { RedisService } from '../../redis/redis.service';
import { AlertService } from '../../common/alerting/alert.service'; import { AlertService } from '../../common/alerting/alert.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users'; import { loadMockUsers } from './mock-users';
import { OrgTreeService } from './org-tree';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 —— /// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
...@@ -100,6 +102,7 @@ export class AuthService { ...@@ -100,6 +102,7 @@ export class AuthService {
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly alert: AlertService, private readonly alert: AlertService,
private readonly orgTree: OrgTreeService,
) {} ) {}
/** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */ /** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
...@@ -588,15 +591,20 @@ export class AuthService { ...@@ -588,15 +591,20 @@ export class AuthService {
} }
const fSub = `mock-friday-${req.role}`; const fSub = `mock-friday-${req.role}`;
const fName = `FRIDAY ${roleNameZh(req.role)}`; const fName = `FRIDAY ${roleNameZh(req.role)}`;
// 数据范围:orgId(真实品牌 GUID / 诊所 id,来自 GET /auth/mock-orgs)优先;
// 否则集团根(全部品牌全部诊所)。⚠️ 身份可造,范围必须落在真实摄入数据上。
const fOrgScope = [req.orgLevel === 'group' || !req.orgId ? 'friday-market' : req.orgId];
const { accessToken, refreshToken } = await this.issueTokens(fridayHost, { const { accessToken, refreshToken } = await this.issueTokens(fridayHost, {
userId: fSub, userId: fSub,
tenantId: 'friday-market', tenantId: 'friday-market',
role: req.role, role: req.role,
orgScope: ['friday-market'], // 集团根 → 展开后 sourceUnits=[]/clinicIds=[] 不限 orgScope: fOrgScope,
dictionary: { clinics: {}, users: { [fSub]: fName } }, // 名字典由 session 从 host.sourceUnitNames / clinicNames 补齐(派生自摄入数据),
// 此处只带造的身份名。
dictionary: { users: { [fSub]: fName } },
}); });
this.logger.log( this.logger.log(
`mock-login: tenant=friday(FRIDAY 市场) role=${req.role} sub=${fSub} orgScope=[friday-market]`, `mock-login: tenant=friday(FRIDAY 市场) role=${req.role} sub=${fSub} orgScope=[${fOrgScope.join(',')}]`,
); );
return { accessToken, refreshToken, expiresIn: this.accessExpiresInSeconds }; return { accessToken, refreshToken, expiresIn: this.accessExpiresInSeconds };
} }
...@@ -676,6 +684,39 @@ export class AuthService { ...@@ -676,6 +684,39 @@ export class AuthService {
} }
/** /**
* **真实**组织结构(品牌 → 诊所)—— 给"快速登录"对话框选数据范围用。
*
* 结构取自 org 树(**从摄入数据 patient_transactions 派生**,零硬编码),
* 名字取自 host.sourceUnitNames / clinicNames(refresh-clinic-names 从摄入源派生)。
* 多品牌宿主(FRIDAY)没有客服花名册 → 身份可造,但数据范围必须落在真实品牌/诊所上。
* mock 关闭时返空。
*/
async listMockOrgs(hostName: string, tenantId: string): Promise<MockOrgsResponse> {
if (!this.isMockLoginEnabled()) return { groupId: tenantId, brands: [] };
const host = await this.prisma.host.findFirst({ where: { name: hostName } });
if (!host) return { groupId: tenantId, brands: [] };
const [tree, names] = await Promise.all([
this.orgTree.getTree(host.id, tenantId),
this.getDisplayNames(host.id),
]);
const brands = (tree.root.children ?? []).map((b) => ({
brandId: b.id,
name: names.brands[b.id] ?? b.id,
clinics: (b.children ?? []).map((c) => ({
clinicId: c.id,
name: names.clinics[c.id] ?? c.id,
})),
}));
// 有名字的品牌排前、诊所多的排前(演示时先看到真实机构)
brands.sort(
(a, b) =>
Number(!!names.brands[b.brandId]) - Number(!!names.brands[a.brandId]) ||
b.clinics.length - a.clinics.length,
);
return { groupId: tenantId, brands };
}
/**
* 客服花名册(给"快速登录"对话框选人用)。 * 客服花名册(给"快速登录"对话框选人用)。
* 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。 * 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。
*/ */
......
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
ExchangeCodeResponse, ExchangeCodeResponse,
MockLoginRequest, MockLoginRequest,
MockLoginResponse, MockLoginResponse,
MockOrgsResponse,
MockUsersResponse, MockUsersResponse,
RefreshTokenResponse, RefreshTokenResponse,
SessionResponse, SessionResponse,
...@@ -41,3 +42,10 @@ export async function mockLogin(req: MockLoginRequest): Promise<MockLoginRespons ...@@ -41,3 +42,10 @@ export async function mockLogin(req: MockLoginRequest): Promise<MockLoginRespons
export async function getMockUsers(): Promise<MockUsersResponse> { export async function getMockUsers(): Promise<MockUsersResponse> {
return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false }); return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false });
} }
/// 真实组织结构(品牌→诊所,派生自摄入数据)— 多品牌宿主快速登录选数据范围用
export async function getMockOrgs(host = 'friday'): Promise<MockOrgsResponse> {
return api.get<MockOrgsResponse>(`/pac/v1/auth/mock-orgs?host=${encodeURIComponent(host)}`, {
auth: false,
});
}
...@@ -127,6 +127,10 @@ export const MockLoginRequestSchema = z.strictObject({ ...@@ -127,6 +127,10 @@ export const MockLoginRequestSchema = z.strictObject({
/// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。 /// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。
/// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。 /// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。
orgLevel: z.enum(['group']).optional(), orgLevel: z.enum(['group']).optional(),
/// 直接指定 org 节点 id(品牌 GUID / 诊所 id)作数据范围 —— 多品牌宿主(FRIDAY)用:
/// 它没有客服花名册,身份是造的,但**品牌/诊所必须是真实摄入数据**(见 GET /auth/mock-orgs)。
/// 给了 orgId 时 orgScope=[orgId];与 orgLevel 互斥(orgLevel=group 优先)。
orgId: z.string().min(1).optional(),
}); });
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>; export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
...@@ -156,6 +160,31 @@ export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema) ...@@ -156,6 +160,31 @@ export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema)
export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>; export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>;
// ============================================================= // =============================================================
// Mock Orgs (GET /pac/v1/auth/mock-orgs) ⭐ 开发 / 试部署用
// =============================================================
//
// 给"快速登录"对话框的**真实**组织结构(品牌 → 诊所),从摄入数据派生的 org 树 +
// host 名字典(sourceUnitNames / clinicNames)。多品牌宿主(FRIDAY)没有客服花名册,
// 身份可造,但数据范围必须落在真实品牌/诊所上 —— 本端点提供可选项。
export const MockOrgClinicSchema = z.object({
clinicId: z.string(),
name: z.string(),
});
export const MockOrgBrandSchema = z.object({
/// 品牌节点 id(= patients.source_unit;多品牌宿主是 GUID)
brandId: z.string(),
name: z.string(),
clinics: z.array(MockOrgClinicSchema),
});
export type MockOrgBrand = z.infer<typeof MockOrgBrandSchema>;
export const MockOrgsResponseSchema = z.object({
/// 集团(租户)节点 id — 集团级登录传它
groupId: z.string(),
brands: z.array(MockOrgBrandSchema),
});
export type MockOrgsResponse = z.infer<typeof MockOrgsResponseSchema>;
// =============================================================
// JWT payload — server-only; never sent to host // JWT payload — server-only; never sent to host
// ============================================================= // =============================================================
......
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