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

+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();
});
});