feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 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:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
1 parent f0d218bd99
commit 4d9eb980af
120 files changed
+13112 -314

No files matched your search

+1 -1
View File
@@ -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: [],
});
}
+12 -9
View File
@@ -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,
+23
View File
@@ -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
View File
@@ -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;
+199
View File
@@ -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();
});
});
+135
View File
@@ -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);
}
+12
View File
@@ -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,
};
+111
View File
@@ -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}`);
});
});
+60
View File
@@ -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);
}