背景: - 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、 权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。 改动: - packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role) 与 user:view 权限点;写接口 strict 校验。 - apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/ user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh 旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。 - apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403; usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。 - e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。 - openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。 影响面: - 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。 - 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。 - 平台导航「用户与组织」由 /platform/users 调整为 /users。 回滚方式: - 代码整体回滚本提交;迁移为 expand,回滚代码不需删表; - 本地如需彻底回退按实施报告 §6 处理。 验证证据: - pnpm format:check / lint / typecheck 通过; - pnpm test:shared 95 / api 60 / web 55; - pnpm test:int:7 个(真实 PG/Redis); - A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6; - 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
1 parent
f0d218bd99
commit
4d9eb980af
120 files changed
+13112
-314
No files matched your search
@@ -16,7 +16,7 @@ function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
|
||||
user: { id: '01J0TEST000000000000000000', name: '测试用户', account: 'test@lingxi.local' },
|
||||
permissions: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,6 +108,13 @@ async function toApiError(response: Response): Promise<ApiError> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */
|
||||
function failRefresh(): false {
|
||||
useAuthStore.getState().clearSession();
|
||||
unauthorizedHandler?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
|
||||
@@ -119,9 +126,9 @@ export function refreshSession(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function doRefresh(): Promise<boolean> {
|
||||
const { refreshToken, clearSession } = useAuthStore.getState();
|
||||
const { refreshToken } = useAuthStore.getState();
|
||||
if (refreshToken === null) {
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -131,12 +138,10 @@ async function doRefresh(): Promise<boolean> {
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
if (!response.ok) {
|
||||
clearSession();
|
||||
unauthorizedHandler?.();
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const accessToken =
|
||||
@@ -148,9 +153,7 @@ async function doRefresh(): Promise<boolean> {
|
||||
? payload.refreshToken
|
||||
: null;
|
||||
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
||||
clearSession();
|
||||
unauthorizedHandler?.();
|
||||
return false;
|
||||
return failRefresh();
|
||||
}
|
||||
useAuthStore.getState().setTokens({
|
||||
accessToken,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* identity 模块接口路径(唯一出处)。
|
||||
* 依据:技术架构 §6.3.1、开发计划附录 D。
|
||||
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
|
||||
*/
|
||||
const USERS = '/users';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
login: '/auth/login',
|
||||
refresh: '/auth/refresh',
|
||||
logout: '/auth/logout',
|
||||
changePassword: '/auth/change-password',
|
||||
me: '/me',
|
||||
users: USERS,
|
||||
user: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}`,
|
||||
userDisable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/disable`,
|
||||
userEnable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/enable`,
|
||||
userCustomers: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/customers`,
|
||||
userTransferCustomers: (userId: string): string =>
|
||||
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
|
||||
departments: '/departments/tree',
|
||||
roles: '/roles',
|
||||
} as const;
|
||||
+536
@@ -4,6 +4,270 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/api/v1/auth/login': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 账号密码登录
|
||||
* @description 返回 accessToken / refreshToken / expiresIn / me
|
||||
*/
|
||||
post: operations['AuthController_login'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/refresh': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 刷新令牌(旋转,旧 refresh 立即失效) */
|
||||
post: operations['AuthController_refresh'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/logout': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 登出
|
||||
* @description 吊销 refresh 令牌所在家族
|
||||
*/
|
||||
post: operations['AuthController_logout'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/change-password': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 修改密码(首登强改) */
|
||||
post: operations['AuthController_changePassword'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/me': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 当前用户、角色、权限点、数据范围 */
|
||||
get: operations['MeController_current'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 用户列表(游标分页)
|
||||
* @description status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码
|
||||
*/
|
||||
get: operations['UsersController_list'];
|
||||
put?: never;
|
||||
/**
|
||||
* 新建用户
|
||||
* @description 初始密码由管理员设定,首登强制修改
|
||||
*/
|
||||
post: operations['UsersController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 用户详情
|
||||
* @description 仅 user:manage 返回 phone 全号
|
||||
*/
|
||||
get: operations['UsersController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新用户(不含账号 / 状态 / 密码) */
|
||||
patch: operations['UsersController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/disable': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 停用用户
|
||||
* @description 停用后旧令牌立即失效;可指定客户转交人
|
||||
*/
|
||||
post: operations['UsersController_disable'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/enable': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 启用用户 */
|
||||
post: operations['UsersController_enable'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 客户转交(占位)
|
||||
* @description A3 客户模块落地前返回 42201
|
||||
*/
|
||||
post: operations['UsersController_transferCustomers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/tree': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 部门树(两级,含成员数与负责人) */
|
||||
get: operations['DepartmentsController_tree'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 新建部门 / 小组
|
||||
* @description 最多两级,在小组下新建返回 42201
|
||||
*/
|
||||
post: operations['DepartmentsController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 部门改名 */
|
||||
patch: operations['DepartmentsController_rename'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/roles': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 角色列表(绑定用) */
|
||||
get: operations['RolesController_list'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -135,6 +399,278 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
AuthController_login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_refresh: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_logout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_changePassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
MeController_current: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_disable: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_enable: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_transferCustomers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_tree: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_rename: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
RolesController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { ApiError, request, setUnauthorizedHandler } from './client';
|
||||
import {
|
||||
changePassword,
|
||||
createUser,
|
||||
disableUser,
|
||||
enableUser,
|
||||
fetchDepartments,
|
||||
fetchMe,
|
||||
fetchRoles,
|
||||
fetchUserCustomers,
|
||||
login,
|
||||
logout,
|
||||
transferCustomers,
|
||||
updateUser,
|
||||
} from './identity';
|
||||
import { fetchUsers } from './users';
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
|
||||
|
||||
const userSummary = {
|
||||
id: USER_ID,
|
||||
name: '陈默',
|
||||
phoneMasked: '138****2046',
|
||||
account: 'chen.mo',
|
||||
departmentId: GROUP_ID,
|
||||
departmentName: '内容生产中心',
|
||||
groupId: GROUP_ID,
|
||||
groupName: '素材一组',
|
||||
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
|
||||
status: 'active',
|
||||
lastLoginAt: '2026-11-02T09:41:00.000Z',
|
||||
createdAt: '2026-09-30T18:40:00.000Z',
|
||||
};
|
||||
|
||||
const me = {
|
||||
user: userSummary,
|
||||
roles: userSummary.roles,
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'all' },
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
function toUrl(input: string | URL | Request | undefined): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input?.url ?? '';
|
||||
}
|
||||
|
||||
function calls(): { url: string; init: RequestInit | undefined }[] {
|
||||
const fetchMock = vi.mocked(globalThis.fetch);
|
||||
return fetchMock.mock.calls.map((call) => ({
|
||||
url: toUrl(call[0]),
|
||||
init: call[1],
|
||||
}));
|
||||
}
|
||||
|
||||
function lastBody(): unknown {
|
||||
const last = calls().at(-1);
|
||||
const body = last?.init?.body;
|
||||
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
|
||||
}
|
||||
|
||||
describe('identity 接口封装', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().clearSession();
|
||||
setUnauthorizedHandler(null);
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
|
||||
permissions: ['user:view'],
|
||||
});
|
||||
});
|
||||
|
||||
it('登录:POST /auth/login,body 为 account/password 且不带 token', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
jsonResponse({
|
||||
accessToken: 'a',
|
||||
refreshToken: 'r',
|
||||
expiresIn: 3600,
|
||||
me,
|
||||
}),
|
||||
);
|
||||
const response = await login({ account: 'chen.mo', password: 'Lx123456' });
|
||||
expect(response.me.permissions).toEqual(['user:view']);
|
||||
expect(calls()[0]?.init?.method).toBe('POST');
|
||||
expect(lastBody()).toEqual({ account: 'chen.mo', password: 'Lx123456' });
|
||||
});
|
||||
|
||||
it('/me、部门与角色列表按共享 schema 解析', async () => {
|
||||
const department = {
|
||||
id: GROUP_ID,
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leader: null,
|
||||
memberCount: 19,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
children: [
|
||||
{
|
||||
id: USER_ID,
|
||||
name: '素材一组',
|
||||
parentId: GROUP_ID,
|
||||
leader: null,
|
||||
memberCount: 9,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse(me))
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [department] }))
|
||||
.mockResolvedValueOnce(jsonResponse({ items: userSummary.roles }));
|
||||
expect((await fetchMe()).user.name).toBe('陈默');
|
||||
const departments = await fetchDepartments();
|
||||
expect(departments.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']);
|
||||
expect((await fetchRoles())[0]?.name).toBe('素材运营');
|
||||
});
|
||||
|
||||
it('用户列表保留 next_cursor,写接口使用约定方法与路径', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [userSummary], next_cursor: 'cursor-2' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: USER_ID }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: USER_ID, name: '花漾家居' }]))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
const list = await fetchUsers({ limit: 10, status: 'active' });
|
||||
expect(list.next_cursor).toBe('cursor-2');
|
||||
expect(
|
||||
await createUser({
|
||||
name: '测试成员',
|
||||
phone: '13900001111',
|
||||
account: 'test.member',
|
||||
password: 'Lx123456',
|
||||
departmentId: GROUP_ID,
|
||||
roleIds: [ROLE_ID],
|
||||
}),
|
||||
).toBe(USER_ID);
|
||||
await updateUser(USER_ID, { name: '陈默' });
|
||||
await disableUser(USER_ID, { reason: '离职' });
|
||||
await enableUser(USER_ID);
|
||||
await transferCustomers(USER_ID, { customerIds: [USER_ID], toUserId: ROLE_ID });
|
||||
expect((await fetchUserCustomers(USER_ID))[0]?.name).toBe('花漾家居');
|
||||
await changePassword({ currentPassword: 'Lx123456', newPassword: 'Lx654321' });
|
||||
await logout('refresh-token');
|
||||
|
||||
const paths = calls().map((call) => ({
|
||||
url: call.url.replace('http://127.0.0.1:3000/api/v1', ''),
|
||||
method: call.init?.method ?? 'GET',
|
||||
}));
|
||||
expect(paths).toContainEqual({ url: '/users', method: 'POST' });
|
||||
expect(paths).toContainEqual({ url: `/users/${USER_ID}`, method: 'PATCH' });
|
||||
expect(paths).toContainEqual({ url: `/users/${USER_ID}/disable`, method: 'POST' });
|
||||
expect(paths).toContainEqual({ url: `/users/${USER_ID}/enable`, method: 'POST' });
|
||||
expect(paths).toContainEqual({
|
||||
url: `/users/${USER_ID}/transfer-customers`,
|
||||
method: 'POST',
|
||||
});
|
||||
expect(paths).toContainEqual({ url: '/auth/change-password', method: 'POST' });
|
||||
expect(paths).toContainEqual({ url: '/auth/logout', method: 'POST' });
|
||||
});
|
||||
|
||||
it('刷新失败时清理会话并触发跳登录', async () => {
|
||||
const handler = vi.fn();
|
||||
setUnauthorizedHandler(handler);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 40101, message: '登录已过期', trace_id: 't-401' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
useAuthStore.getState().setTokens({ accessToken: 'old', refreshToken: 'refresh-1' });
|
||||
await expect(request('/me')).rejects.toBeInstanceOf(ApiError);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
type ChangePasswordInput,
|
||||
changePasswordInputSchema,
|
||||
type CreateUserInput,
|
||||
createUserInputSchema,
|
||||
type DepartmentNode,
|
||||
departmentNodeSchema,
|
||||
type DisableUserInput,
|
||||
disableUserInputSchema,
|
||||
type LoginInput,
|
||||
loginInputSchema,
|
||||
type LoginResponse,
|
||||
loginResponseSchema,
|
||||
type MeResponse,
|
||||
meResponseSchema,
|
||||
type RoleSummary,
|
||||
roleSummarySchema,
|
||||
type TransferCustomersInput,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateUserInput,
|
||||
updateUserInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
/** 客户摘要:A3 客户模块落地前的转交占位契约(仅 id + name) */
|
||||
export interface CustomerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) });
|
||||
const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough();
|
||||
|
||||
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
|
||||
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
|
||||
const candidate: unknown[] = Array.isArray(payload)
|
||||
? payload
|
||||
: isRecord(payload) && Array.isArray(payload.items)
|
||||
? payload.items
|
||||
: [];
|
||||
return candidate.map((item) => itemSchema.parse(item));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<LoginResponse> {
|
||||
const body = loginInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.login, { method: 'POST', auth: false, body });
|
||||
return loginResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function logout(refreshToken: string | null): Promise<void> {
|
||||
const body: { refreshToken?: string } = refreshToken === null ? {} : { refreshToken };
|
||||
await request<unknown>(ENDPOINTS.logout, { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function changePassword(input: ChangePasswordInput): Promise<void> {
|
||||
const body = changePasswordInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.changePassword, { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<MeResponse> {
|
||||
const payload: unknown = await request(ENDPOINTS.me);
|
||||
return meResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */
|
||||
export async function fetchDepartments(): Promise<DepartmentNode[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.departments);
|
||||
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const flat: DepartmentNode[] = [];
|
||||
const walk = (nodes: unknown[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (!isRecord(node)) {
|
||||
continue;
|
||||
}
|
||||
flat.push(departmentNodeSchema.parse(node));
|
||||
const children: unknown = node.children;
|
||||
if (Array.isArray(children)) {
|
||||
walk(children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(raw);
|
||||
return flat;
|
||||
}
|
||||
|
||||
export async function fetchRoles(): Promise<RoleSummary[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.roles);
|
||||
return parseList(payload, roleSummarySchema);
|
||||
}
|
||||
|
||||
/** 新建用户:返回新用户 id;后端若未返回 id,则由调用方回落到列表页 */
|
||||
export async function createUser(input: CreateUserInput): Promise<string | null> {
|
||||
const body = createUserInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
|
||||
const parsed = createdUserSchema.safeParse(payload);
|
||||
return parsed.success ? parsed.data.id : null;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, input: UpdateUserInput): Promise<void> {
|
||||
const body = updateUserInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.user(userId), { method: 'PATCH', body });
|
||||
}
|
||||
|
||||
export async function disableUser(userId: string, input: DisableUserInput): Promise<void> {
|
||||
const body = disableUserInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.userDisable(userId), { method: 'POST', body });
|
||||
}
|
||||
|
||||
export async function enableUser(userId: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.userEnable(userId), { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function transferCustomers(
|
||||
userId: string,
|
||||
input: TransferCustomersInput,
|
||||
): Promise<void> {
|
||||
const body = transferCustomersInputSchema.parse(input);
|
||||
await request<unknown>(ENDPOINTS.userTransferCustomers(userId), { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** 负责客户列表(A3 接口落地前调用会 404,由 hook 降级为空态) */
|
||||
export async function fetchUserCustomers(userId: string): Promise<CustomerSummary[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.userCustomers(userId));
|
||||
return parseList(payload, customerSummarySchema);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UserListQueryInput } from './users';
|
||||
|
||||
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
|
||||
export const queryKeys = {
|
||||
me: ['me'] as const,
|
||||
users: ['users'] as const,
|
||||
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
|
||||
user: (userId: string) => ['users', 'detail', userId] as const,
|
||||
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
|
||||
departments: ['departments'] as const,
|
||||
roles: ['roles'] as const,
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { fetchUserDetail, fetchUsers, toUserListSearchParams } from './users';
|
||||
|
||||
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
|
||||
const DEPT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
|
||||
const summary = {
|
||||
id: USER_ID,
|
||||
name: '陈默',
|
||||
phoneMasked: '138****2046',
|
||||
account: 'chen.mo',
|
||||
departmentId: DEPT_ID,
|
||||
departmentName: '内容生产中心',
|
||||
groupId: null,
|
||||
groupName: '素材一组',
|
||||
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
|
||||
status: 'active',
|
||||
lastLoginAt: '2026-11-02T09:41:00.000Z',
|
||||
createdAt: '2026-09-30T18:40:00.000Z',
|
||||
};
|
||||
|
||||
describe('用户列表请求参数', () => {
|
||||
it('按后端 query 约定拼装 snake_case 参数,空值不传', () => {
|
||||
const params = toUserListSearchParams({
|
||||
limit: 10,
|
||||
status: 'active',
|
||||
sort: 'last_login',
|
||||
order: 'desc',
|
||||
keyword: ' 陈 ',
|
||||
});
|
||||
expect(params.get('limit')).toBe('10');
|
||||
expect(params.get('status')).toBe('active');
|
||||
expect(params.get('sort')).toBe('last_login');
|
||||
expect(params.get('order')).toBe('desc');
|
||||
expect(params.get('keyword')).toBe('陈');
|
||||
expect(params.has('department_id')).toBe(false);
|
||||
expect(params.has('cursor')).toBe(false);
|
||||
});
|
||||
|
||||
it('带游标与部门/角色筛选时完整输出', () => {
|
||||
const params = toUserListSearchParams({
|
||||
limit: 10,
|
||||
cursor: 'cursor-2',
|
||||
status: 'all',
|
||||
sort: 'created_at',
|
||||
order: 'asc',
|
||||
department_id: DEPT_ID,
|
||||
role_id: ROLE_ID,
|
||||
});
|
||||
expect(params.toString()).toBe(
|
||||
`limit=10&cursor=cursor-2&department_id=${DEPT_ID}&role_id=${ROLE_ID}&status=all&sort=created_at&order=asc`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function toUrl(input: string | URL | Request | undefined): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input?.url ?? '';
|
||||
}
|
||||
|
||||
describe('用户列表请求', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
|
||||
permissions: ['user:view'],
|
||||
});
|
||||
});
|
||||
|
||||
it('解析列表信封与详情', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ items: [summary], next_cursor: 'cursor-2' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
...summary,
|
||||
phone: '13800002046',
|
||||
mustChangePassword: true,
|
||||
updatedAt: '2026-11-02T09:41:00.000Z',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
|
||||
const list = await fetchUsers({ limit: 10, status: 'active' });
|
||||
expect(list.items).toHaveLength(1);
|
||||
expect(list.next_cursor).toBe('cursor-2');
|
||||
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/users?limit=10&status=active');
|
||||
|
||||
const detail = await fetchUserDetail(USER_ID);
|
||||
expect(detail.phone).toBe('13800002046');
|
||||
expect(detail.mustChangePassword).toBe(true);
|
||||
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/users/${USER_ID}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type ListResponse,
|
||||
listResponseSchema,
|
||||
type UserDetail,
|
||||
userDetailSchema,
|
||||
type UserListQuery,
|
||||
type UserSummary,
|
||||
userSummarySchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
const userListResponseSchema = listResponseSchema(userSummarySchema);
|
||||
|
||||
/** 用户列表筛选与游标分页查询(query 参数 snake_case) */
|
||||
export interface UserListQueryInput extends Partial<UserListQuery> {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/** 组装 GET /users 的 query 参数;空值不落 query,避免后端严格校验报错 */
|
||||
export function toUserListSearchParams(query: UserListQueryInput): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(query.limit));
|
||||
if (query.cursor !== undefined && query.cursor !== '') {
|
||||
params.set('cursor', query.cursor);
|
||||
}
|
||||
if (query.keyword !== undefined && query.keyword.trim() !== '') {
|
||||
params.set('keyword', query.keyword.trim());
|
||||
}
|
||||
if (query.department_id !== undefined) {
|
||||
params.set('department_id', query.department_id);
|
||||
}
|
||||
if (query.role_id !== undefined) {
|
||||
params.set('role_id', query.role_id);
|
||||
}
|
||||
if (query.status !== undefined) {
|
||||
params.set('status', query.status);
|
||||
}
|
||||
if (query.sort !== undefined) {
|
||||
params.set('sort', query.sort);
|
||||
}
|
||||
if (query.order !== undefined) {
|
||||
params.set('order', query.order);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function fetchUsers(query: UserListQueryInput): Promise<ListResponse<UserSummary>> {
|
||||
const search = toUserListSearchParams(query).toString();
|
||||
const path = search === '' ? ENDPOINTS.users : `${ENDPOINTS.users}?${search}`;
|
||||
const payload: unknown = await request(path);
|
||||
return userListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchUserDetail(userId: string): Promise<UserDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.user(userId));
|
||||
return userDetailSchema.parse(payload);
|
||||
}
|
||||
Reference in new issue
Block a user