Commit 379a4af8 by luoqi

feat(auth): 模拟登录接入诊所客服名册 + 列表诊所筛选按可见范围

- 数据:data/jvs-dw/users.json — 116 位在岗客服(回访表口径·近12月),
  结构 = 未来 users/user_clinics 两表形状(externalId/name/tenant/roles/clinics)
- 后端:mock-users.ts 加载器(cwd 解析+缓存);mockLogin 支持 userExternalId
  (选具体客服→sub=externalId/真实姓名/该客服诊所);新增 GET /auth/mock-users
- 前端:快速登录改为「角色(权限)+ 选客服」合并式;名册拉不到时降级回通用网格
- 前端:列表页诊所筛选选项改用 user.clinicIds(RBAC 可见范围),名取 dictionary
  (原误用全 tenant 名表,与后端 scope.clinicIds 过滤对齐)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 3725c9ad
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
ExchangeCodeResponseDto, ExchangeCodeResponseDto,
MockLoginRequestDto, MockLoginRequestDto,
MockLoginResponseDto, MockLoginResponseDto,
MockUsersResponseDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
RefreshTokenResponseDto, RefreshTokenResponseDto,
TokenExchangeRequestDto, TokenExchangeRequestDto,
...@@ -59,4 +60,16 @@ export class AuthController { ...@@ -59,4 +60,16 @@ export class AuthController {
mockLogin(@Body() dto: MockLoginRequestDto) { mockLogin(@Body() dto: MockLoginRequestDto) {
return this.auth.mockLogin(dto); return this.auth.mockLogin(dto);
} }
@Public()
@Get('mock-users')
@ZodResponse({ status: 200, type: MockUsersResponseDto })
@ApiOperation({
summary: '客服花名册(开发 / 试部署用)— 给"快速登录"对话框选人',
description:
'派生自 data/jvs-dw/users.json(回访表 · 近 12 月在岗)。mock 登录禁用时返空数组。',
})
mockUsers() {
return { users: this.auth.listMockUsers() };
}
} }
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
type AccessTokenPayload, type AccessTokenPayload,
type ExchangeCodeResponse, type ExchangeCodeResponse,
type MockLoginRequest, type MockLoginRequest,
type MockUser,
type Permission, type Permission,
type RefreshTokenResponse, type RefreshTokenResponse,
type TokenDictionary, type TokenDictionary,
...@@ -19,6 +20,7 @@ import { randomCode, verifySecret } from '@pac/utils'; ...@@ -19,6 +20,7 @@ import { randomCode, verifySecret } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
const REFRESH_PREFIX = 'pac:refresh:'; const REFRESH_PREFIX = 'pac:refresh:';
...@@ -264,19 +266,38 @@ export class AuthService { ...@@ -264,19 +266,38 @@ export class AuthService {
); );
} }
const clinicIds = Object.keys(preset.clinics); // 默认:通用预制身份(sub=mock-<tenant>-<role>,看该 tenant 全部诊所)
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) {
const cs = loadMockUsers().find(
(u) => u.tenant === req.tenant && u.externalId === req.userExternalId,
);
if (!cs) {
throw new BizError(
ApiCode.CLIENT_VALIDATION_FAILED,
`unknown mock user: ${req.tenant}/${req.userExternalId}`,
);
}
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);
}
const dictionary: TokenDictionary = { const dictionary: TokenDictionary = {
clinics: preset.clinics as Record<string, string>, clinics: preset.clinics as Record<string, string>,
users: {
// 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用) // 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用)
[`mock-${req.tenant}-${req.role}`]: users: { [sub]: subName },
MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role),
},
}; };
const permissions = this.resolvePermissions(req.role); const permissions = this.resolvePermissions(req.role);
const accessToken = await this.signAccessToken({ const accessToken = await this.signAccessToken({
sub: `mock-${req.tenant}-${req.role}`, sub,
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
...@@ -285,7 +306,7 @@ export class AuthService { ...@@ -285,7 +306,7 @@ export class AuthService {
dictionary, dictionary,
}); });
const refreshToken = await this.signRefreshToken({ const refreshToken = await this.signRefreshToken({
sub: `mock-${req.tenant}-${req.role}`, sub,
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
...@@ -296,10 +317,38 @@ export class AuthService { ...@@ -296,10 +317,38 @@ export class AuthService {
this.logger.log( this.logger.log(
`mock-login: tenant=${req.tenant}(${preset.tenantNameZh}) role=${req.role} ` + `mock-login: tenant=${req.tenant}(${preset.tenantNameZh}) role=${req.role} ` +
`clinics=${clinicIds.length} hostId=${host.id}`, `sub=${sub}(${subName}) clinics=${clinicIds.length} hostId=${host.id}`,
); );
return { accessToken, refreshToken, expiresIn }; return { accessToken, refreshToken, expiresIn };
} }
/**
* 客服花名册(给"快速登录"对话框选人用)。
* 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。
*/
listMockUsers(): MockUser[] {
if (!this.isMockLoginEnabled()) return [];
return loadMockUsers()
.map((u) => {
const preset = this.MOCK_PRESETS[u.tenant];
const clinicDict = (preset?.clinics ?? {}) as Record<string, string>;
return {
externalId: u.externalId,
name: u.name,
tenant: u.tenant,
lastActiveAt: u.lastActiveAt,
contactCount: u.clinics.reduce((s, c) => s + (c.contactCount ?? 0), 0),
clinics: u.clinics.map((c) => ({
clinicId: c.clinicId,
name: clinicDict[c.clinicId] ?? c.clinicId,
})),
};
})
.sort(
(a, b) =>
a.tenant.localeCompare(b.tenant) || b.contactCount - a.contactCount,
);
}
} }
function roleNameZh(role: UserRole): string { function roleNameZh(role: UserRole): string {
......
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
ExchangeCodeResponseSchema, ExchangeCodeResponseSchema,
MockLoginRequestSchema, MockLoginRequestSchema,
MockLoginResponseSchema, MockLoginResponseSchema,
MockUsersResponseSchema,
RefreshTokenRequestSchema, RefreshTokenRequestSchema,
RefreshTokenResponseSchema, RefreshTokenResponseSchema,
TokenExchangeRequestSchema, TokenExchangeRequestSchema,
...@@ -18,3 +19,4 @@ export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSche ...@@ -18,3 +19,4 @@ export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSche
export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {} export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {}
export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {} export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {} export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {}
export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {}
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Mock 客服花名册 loader —— 读 `data/<host>/users.json`(派生自回访表,近 12 月在岗)。
*
* 这是"文件先行"的临时方案(见 docs 讨论):mock-login 选人用,**非 PAC 主数据**。
* 文件结构 = 未来 `users` + `user_clinics` 两表的形状,以后升表直接灌、不返工。
*
* 路径策略(跟 skill-registry 同理由 — 用 cwd 不用 __dirname,dev/prod 都稳):
* - env PAC_MOCK_USERS_FILE 覆盖(测试/换 host)
* - 默认 `<cwd>/data/jvs-dw/users.json`(data/ 不编译,dev/prod 同路径)
* - 文件不存在 → 返回 [](picker 不显示,不报错)
*
* 内存缓存:启动后读一次;文件由离线 CLI 刷新,运行时不变(改了重启 pac-service)。
*/
export interface RawMockUser {
externalId: string;
name: string;
tenant: 'ruier' | 'ruitai';
roles: string[];
lastActiveAt: string;
clinics: Array<{ clinicId: string; lastActiveAt: string; contactCount: number }>;
}
export function resolveMockUsersFile(): string {
return process.env.PAC_MOCK_USERS_FILE || join(process.cwd(), 'data/jvs-dw/users.json');
}
let cache: RawMockUser[] | null = null;
export function loadMockUsers(): RawMockUser[] {
if (cache) return cache;
const path = resolveMockUsersFile();
if (!existsSync(path)) {
cache = [];
return cache;
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as RawMockUser[];
cache = Array.isArray(parsed) ? parsed : [];
} catch {
cache = [];
}
return cache;
}
...@@ -216,12 +216,12 @@ export function PlansListApp() { ...@@ -216,12 +216,12 @@ export function PlansListApp() {
setQuery({ status: s, page: 1 }); setQuery({ status: s, page: 1 });
clearSelected(); clearSelected();
}; };
// 诊所多选筛选 — 选项来自 token dictionary.clinics(用户可见诊所);状态存进 query.targetClinicIds // 诊所多选筛选 — 选项 = 当前用户**可见诊所**(token.clinicIds = RBAC 范围),名取自 dictionary.clinics
const clinicOptions = useMemo( // (dictionary 只是 id→名 查表,可能含范围外诊所;真正的可见范围以 clinicIds 为准)。状态存 query.targetClinicIds
() => const clinicOptions = useMemo(() => {
Object.entries(user?.dictionary?.clinics ?? {}).map(([id, name]) => ({ id, name: String(name) })), const dict = user?.dictionary?.clinics ?? {};
[user?.dictionary?.clinics], return (user?.clinicIds ?? []).map((id) => ({ id, name: String(dict[id] ?? id) }));
); }, [user?.clinicIds, user?.dictionary?.clinics]);
const selectedClinics = query.targetClinicIds ?? []; const selectedClinics = query.targetClinicIds ?? [];
const setClinics = (ids: string[]) => { const setClinics = (ids: string[]) => {
setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 }); setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
......
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
ExchangeCodeResponse, ExchangeCodeResponse,
MockLoginRequest, MockLoginRequest,
MockLoginResponse, MockLoginResponse,
MockUsersResponse,
RefreshTokenResponse, RefreshTokenResponse,
} from '@pac/types'; } from '@pac/types';
import { api } from './api-client'; import { api } from './api-client';
...@@ -29,3 +30,8 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse> ...@@ -29,3 +30,8 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse>
export async function mockLogin(req: MockLoginRequest): Promise<MockLoginResponse> { export async function mockLogin(req: MockLoginRequest): Promise<MockLoginResponse> {
return api.post<MockLoginResponse>('/pac/v1/auth/mock-login', req, { auth: false }); return api.post<MockLoginResponse>('/pac/v1/auth/mock-login', req, { auth: false });
} }
/// 客服花名册(开发 / 试部署用)— "快速登录"对话框选人;mock 关闭时 users 为空
export async function getMockUsers(): Promise<MockUsersResponse> {
return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false });
}
...@@ -114,6 +114,9 @@ export const MockLoginRequestSchema = z.strictObject({ ...@@ -114,6 +114,9 @@ export const MockLoginRequestSchema = z.strictObject({
tenant: z.enum(['ruier', 'ruitai']), tenant: z.enum(['ruier', 'ruitai']),
/// PAC 角色 — staff / leader / admin /// PAC 角色 — staff / leader / admin
role: UserRoleSchema, role: UserRoleSchema,
/// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。
/// 给了则用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所);不给走通用预制身份。
userExternalId: z.string().optional(),
}); });
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>; export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
...@@ -122,6 +125,27 @@ export const MockLoginResponseSchema = ExchangeCodeResponseSchema; ...@@ -122,6 +125,27 @@ export const MockLoginResponseSchema = ExchangeCodeResponseSchema;
export type MockLoginResponse = z.infer<typeof MockLoginResponseSchema>; export type MockLoginResponse = z.infer<typeof MockLoginResponseSchema>;
// ============================================================= // =============================================================
// Mock Users (GET /pac/v1/auth/mock-users) ⭐ 开发 / 试部署用
// =============================================================
//
// 给"快速登录"对话框的客服花名册(派生自 data/<host>/users.json,回访表口径 · 近 12 月在岗)。
// 选一个客服 → POST /auth/mock-login { tenant, role, userExternalId } 以其真实身份登录。
export const MockUserSchema = z.object({
externalId: z.string(),
name: z.string(),
tenant: z.enum(['ruier', 'ruitai']),
lastActiveAt: z.string(),
/// 近 12 月回访量(各诊所合计,排序/展示用)
contactCount: z.number().int(),
/// 所属诊所(带中文名,展示用)
clinics: z.array(z.object({ clinicId: z.string(), name: z.string() })),
});
export type MockUser = z.infer<typeof MockUserSchema>;
export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema) });
export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>;
// =============================================================
// 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