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 { ZodResponse } from 'nestjs-zod';
import { Public } from '../../common/decorators/public.decorator';
......@@ -8,6 +8,7 @@ import {
ExchangeCodeResponseDto,
MockLoginRequestDto,
MockLoginResponseDto,
MockUsersResponseDto,
RefreshTokenRequestDto,
RefreshTokenResponseDto,
TokenExchangeRequestDto,
......@@ -59,4 +60,16 @@ export class AuthController {
mockLogin(@Body() dto: MockLoginRequestDto) {
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 {
type AccessTokenPayload,
type ExchangeCodeResponse,
type MockLoginRequest,
type MockUser,
type Permission,
type RefreshTokenResponse,
type TokenDictionary,
......@@ -19,6 +20,7 @@ import { randomCode, verifySecret } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users';
const CODE_PREFIX = 'pac:exchange-code:';
const REFRESH_PREFIX = 'pac:refresh:';
......@@ -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 = {
clinics: preset.clinics as Record<string, string>,
users: {
// 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用)
[`mock-${req.tenant}-${req.role}`]:
MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role),
},
users: { [sub]: subName },
};
const permissions = this.resolvePermissions(req.role);
const accessToken = await this.signAccessToken({
sub: `mock-${req.tenant}-${req.role}`,
sub,
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
......@@ -285,7 +306,7 @@ export class AuthService {
dictionary,
});
const refreshToken = await this.signRefreshToken({
sub: `mock-${req.tenant}-${req.role}`,
sub,
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
......@@ -296,10 +317,38 @@ export class AuthService {
this.logger.log(
`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 };
}
/**
* 客服花名册(给"快速登录"对话框选人用)。
* 派生自 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 {
......
......@@ -4,6 +4,7 @@ import {
ExchangeCodeResponseSchema,
MockLoginRequestSchema,
MockLoginResponseSchema,
MockUsersResponseSchema,
RefreshTokenRequestSchema,
RefreshTokenResponseSchema,
TokenExchangeRequestSchema,
......@@ -18,3 +19,4 @@ export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSche
export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {}
export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
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() {
setQuery({ status: s, page: 1 });
clearSelected();
};
// 诊所多选筛选 — 选项来自 token dictionary.clinics(用户可见诊所);状态存进 query.targetClinicIds
const clinicOptions = useMemo(
() =>
Object.entries(user?.dictionary?.clinics ?? {}).map(([id, name]) => ({ id, name: String(name) })),
[user?.dictionary?.clinics],
);
// 诊所多选筛选 — 选项 = 当前用户**可见诊所**(token.clinicIds = RBAC 范围),名取自 dictionary.clinics
// (dictionary 只是 id→名 查表,可能含范围外诊所;真正的可见范围以 clinicIds 为准)。状态存 query.targetClinicIds
const clinicOptions = useMemo(() => {
const dict = user?.dictionary?.clinics ?? {};
return (user?.clinicIds ?? []).map((id) => ({ id, name: String(dict[id] ?? id) }));
}, [user?.clinicIds, user?.dictionary?.clinics]);
const selectedClinics = query.targetClinicIds ?? [];
const setClinics = (ids: string[]) => {
setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
......
......@@ -4,6 +4,7 @@ import type {
ExchangeCodeResponse,
MockLoginRequest,
MockLoginResponse,
MockUsersResponse,
RefreshTokenResponse,
} from '@pac/types';
import { api } from './api-client';
......@@ -29,3 +30,8 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse>
export async function mockLogin(req: MockLoginRequest): Promise<MockLoginResponse> {
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({
tenant: z.enum(['ruier', 'ruitai']),
/// PAC 角色 — staff / leader / admin
role: UserRoleSchema,
/// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。
/// 给了则用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所);不给走通用预制身份。
userExternalId: z.string().optional(),
});
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
......@@ -122,6 +125,27 @@ export const MockLoginResponseSchema = ExchangeCodeResponseSchema;
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
// =============================================================
......
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