背景: - 开发计划 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
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* identity 模块共享契约(A1:组织与账号、角色、认证与会话)
|
||||
* 依据:PRD §5.4.1 / §5.4.3、技术架构 §6.3.1 / §6.2、开发计划 §6.2 / §7.1、工程规范 04。
|
||||
*
|
||||
* 命名约定(工程规范 04 §2、C-01):
|
||||
* - JSON body 用 camelCase(如 departmentId、roleIds);
|
||||
* - query 用 snake_case(如 department_id、role_id),分页字段复用 cursor/limit;
|
||||
* - 写接口 body 用 `.strict()` 拒绝未知字段,避免静默丢弃。
|
||||
*
|
||||
* 脱敏铁律:列表/详情默认只回 masked 手机号(phoneMasked),全号(phone)
|
||||
* 仅在调用者具备 `user:manage` 时由后端追加返回,其余情况该字段为 undefined。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { cursorPaginationQuerySchema } from './api.js';
|
||||
import { isoDateTimeSchema, ulidSchema } from './common.js';
|
||||
|
||||
/** 用户状态:停用即时生效、账号不删除(PRD §5.4.1「离职停用不删」) */
|
||||
export const USER_STATUSES = ['active', 'disabled'] as const;
|
||||
|
||||
/** 密码规则(A1-BE-02):8–64 位,且至少包含字母与数字 */
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
export const PASSWORD_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 组织架构深度上限:部门 / 小组两级(PRD §5.4.1「部门 / 小组两级,不做无限层级」)。
|
||||
* 注意:这是结构规则、不是业务阈值,故以常量固化而非放系统配置。
|
||||
*/
|
||||
export const MAX_DEPARTMENT_DEPTH = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础字段
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 手机号:11 位大陆手机号 */
|
||||
export const phoneSchema = z
|
||||
.string()
|
||||
.regex(/^1[3-9]\d{9}$/, '请输入 11 位手机号(1 开头,第二位 3–9)');
|
||||
|
||||
/** 登录账号:3–64 位,字母或数字开头,可含点 / 下划线 / 中划线(邮箱或工号风格) */
|
||||
export const accountSchema = z
|
||||
.string()
|
||||
.min(3, '账号至少 3 个字符')
|
||||
.max(64, '账号最多 64 个字符')
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,63}$/,
|
||||
'账号需以字母或数字开头,只能包含字母、数字、点、下划线、中划线',
|
||||
);
|
||||
|
||||
/** 密码:8–64 位,至少包含字母与数字 */
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(PASSWORD_MIN_LENGTH, `密码至少 ${PASSWORD_MIN_LENGTH} 位`)
|
||||
.max(PASSWORD_MAX_LENGTH, `密码最多 ${PASSWORD_MAX_LENGTH} 位`)
|
||||
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, '密码必须同时包含字母和数字');
|
||||
|
||||
/** 姓名:1–20 个字符(去首尾空白后校验) */
|
||||
export const userNameSchema = z.string().trim().min(1, '请输入姓名').max(20, '姓名最多 20 个字符');
|
||||
|
||||
/** 备注 / 停用原因:≤ 200 个字符 */
|
||||
const remarkSchema = z.string().max(200, '备注最多 200 个字符').optional();
|
||||
|
||||
/** 角色 ID 列表:至少 1 个(一人可多角色,PRD §5.4.1) */
|
||||
const roleIdsSchema = z.array(ulidSchema).min(1, '至少分配一个角色');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 角色
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 角色摘要(用户列表中随行展示;完整角色由 A2 角色管理提供) */
|
||||
export const roleSummarySchema = z.object({
|
||||
id: ulidSchema,
|
||||
code: z.string().min(1, '角色编码不能为空'),
|
||||
name: z.string().min(1, '角色名称不能为空'),
|
||||
isSystem: z.boolean(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RoleSummary = z.infer<typeof roleSummarySchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 部门
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH) */
|
||||
export const departmentNodeSchema = z.object({
|
||||
id: ulidSchema,
|
||||
name: z.string().min(1, '部门名称不能为空'),
|
||||
parentId: ulidSchema.nullable(),
|
||||
leader: z
|
||||
.object({
|
||||
id: ulidSchema,
|
||||
name: z.string().min(1, '负责人姓名不能为空'),
|
||||
})
|
||||
.nullable(),
|
||||
memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'),
|
||||
createdAt: isoDateTimeSchema,
|
||||
});
|
||||
|
||||
export type DepartmentNode = z.infer<typeof departmentNodeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 用户状态(active / disabled) */
|
||||
export const userStatusSchema = z.enum(USER_STATUSES, {
|
||||
errorMap: () => ({ message: '用户状态只能是 active 或 disabled' }),
|
||||
});
|
||||
|
||||
export type UserStatus = z.infer<typeof userStatusSchema>;
|
||||
|
||||
/** 用户摘要:列表项 / 详情基础部分;**永不含手机号全号** */
|
||||
export const userSummarySchema = z.object({
|
||||
id: ulidSchema,
|
||||
name: z.string().min(1, '用户姓名不能为空'),
|
||||
phoneMasked: z.string().min(1, '手机号掩码不能为空'),
|
||||
account: z.string().min(1, '账号不能为空'),
|
||||
departmentId: ulidSchema,
|
||||
departmentName: z.string().min(1, '部门名称不能为空'),
|
||||
groupId: ulidSchema.nullable(),
|
||||
groupName: z.string().nullable(),
|
||||
roles: z.array(roleSummarySchema),
|
||||
status: userStatusSchema,
|
||||
lastLoginAt: isoDateTimeSchema.nullable(),
|
||||
createdAt: isoDateTimeSchema,
|
||||
});
|
||||
|
||||
export type UserSummary = z.infer<typeof userSummarySchema>;
|
||||
|
||||
/**
|
||||
* 用户详情 = 摘要 + 管理字段。
|
||||
* `phone`(11 位全号)仅在请求者具备 `user:manage` 时返回;不返回时为 undefined。
|
||||
*/
|
||||
export const userDetailSchema = userSummarySchema.extend({
|
||||
phone: phoneSchema.optional(),
|
||||
mustChangePassword: z.boolean(),
|
||||
updatedAt: isoDateTimeSchema,
|
||||
lastLoginIp: z.string().max(64, '登录 IP 最多 64 个字符').nullable().optional(),
|
||||
disabledAt: isoDateTimeSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export type UserDetail = z.infer<typeof userDetailSchema>;
|
||||
|
||||
/** 用户列表筛选状态:默认 active(停用用户默认隐藏,需显式查 all / disabled) */
|
||||
export const USER_LIST_STATUSES = ['all', 'active', 'disabled'] as const;
|
||||
|
||||
export const userListStatusSchema = z.enum(USER_LIST_STATUSES, {
|
||||
errorMap: () => ({ message: '筛选状态只能是 all / active / disabled' }),
|
||||
});
|
||||
|
||||
export type UserListStatus = z.infer<typeof userListStatusSchema>;
|
||||
|
||||
/** 用户列表排序字段(A1-FE-02 排序列):最近登录 / 创建时间,默认最近登录 */
|
||||
export const USER_LIST_SORTS = ['last_login', 'created_at'] as const;
|
||||
|
||||
export const userListSortSchema = z.enum(USER_LIST_SORTS, {
|
||||
errorMap: () => ({ message: '排序字段只能是 last_login 或 created_at' }),
|
||||
});
|
||||
|
||||
export type UserListSort = z.infer<typeof userListSortSchema>;
|
||||
|
||||
/** 排序方向:asc / desc,默认 desc */
|
||||
export const userListOrderSchema = z.enum(['asc', 'desc'], {
|
||||
errorMap: () => ({ message: '排序方向只能是 asc 或 desc' }),
|
||||
});
|
||||
|
||||
export type UserListOrder = z.infer<typeof userListOrderSchema>;
|
||||
|
||||
/**
|
||||
* 用户列表 query(snake_case,工程规范 04 §2):
|
||||
* cursor/limit + 关键词/部门/角色/状态 + 排序(sort/order)。
|
||||
*/
|
||||
export const userListQuerySchema = cursorPaginationQuerySchema.extend({
|
||||
keyword: z.string().trim().max(64, '关键词最多 64 个字符').optional(),
|
||||
department_id: ulidSchema.optional(),
|
||||
role_id: ulidSchema.optional(),
|
||||
status: userListStatusSchema.default('active'),
|
||||
sort: userListSortSchema.default('last_login'),
|
||||
order: userListOrderSchema.default('desc'),
|
||||
});
|
||||
|
||||
export type UserListQuery = z.infer<typeof userListQuerySchema>;
|
||||
|
||||
/** 新建用户入参(body camelCase,写接口 strict) */
|
||||
export const createUserInputSchema = z
|
||||
.object({
|
||||
name: userNameSchema,
|
||||
phone: phoneSchema,
|
||||
account: accountSchema,
|
||||
password: passwordSchema,
|
||||
departmentId: ulidSchema,
|
||||
roleIds: roleIdsSchema,
|
||||
remark: remarkSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateUserInput = z.infer<typeof createUserInputSchema>;
|
||||
|
||||
/** 更新用户入参:account 不可改,状态变更走停用 / 启用专用接口 */
|
||||
export const updateUserInputSchema = z
|
||||
.object({
|
||||
name: userNameSchema.optional(),
|
||||
phone: phoneSchema.optional(),
|
||||
departmentId: ulidSchema.optional(),
|
||||
roleIds: roleIdsSchema.optional(),
|
||||
remark: remarkSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateUserInput = z.infer<typeof updateUserInputSchema>;
|
||||
|
||||
/** 停用用户入参:可填写原因,可指定客户转交接收人 */
|
||||
export const disableUserInputSchema = z
|
||||
.object({
|
||||
reason: remarkSchema,
|
||||
transferToUserId: ulidSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DisableUserInput = z.infer<typeof disableUserInputSchema>;
|
||||
|
||||
/**
|
||||
* 客户转交入参(占位):客户模块 A3 落地前,后端只校验结构并返回 42201;
|
||||
* A3 完成后customerIds 对应客户负责人批量转交。
|
||||
*/
|
||||
export const transferCustomersInputSchema = z
|
||||
.object({
|
||||
customerIds: z.array(ulidSchema).min(1, '至少选择一个客户'),
|
||||
toUserId: ulidSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type TransferCustomersInput = z.infer<typeof transferCustomersInputSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证与会话
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 登录入参(账号密码登录;SSO 适配器 A1 只留接口不实现) */
|
||||
export const loginInputSchema = z
|
||||
.object({
|
||||
account: accountSchema,
|
||||
password: z
|
||||
.string()
|
||||
.min(1, '请输入密码')
|
||||
.max(PASSWORD_MAX_LENGTH, `密码最多 ${PASSWORD_MAX_LENGTH} 位`),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type LoginInput = z.infer<typeof loginInputSchema>;
|
||||
|
||||
/** 刷新 access token 入参(refresh 旋转,旧 refresh 立即失效) */
|
||||
export const refreshInputSchema = z
|
||||
.object({
|
||||
refreshToken: z.string().min(1, '刷新令牌不能为空'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type RefreshInput = z.infer<typeof refreshInputSchema>;
|
||||
|
||||
/** 登出入参:refreshToken 可选(无法提供时后端按当前会话吊销) */
|
||||
export const logoutInputSchema = z
|
||||
.object({
|
||||
refreshToken: z.string().min(1, '刷新令牌不能为空').optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type LogoutInput = z.infer<typeof logoutInputSchema>;
|
||||
|
||||
/** 修改密码入参:newPassword 与新建用户共用同一密码策略 */
|
||||
export const changePasswordInputSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, '请输入当前密码'),
|
||||
newPassword: passwordSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordInputSchema>;
|
||||
|
||||
/** 令牌对:expiresIn 为 access token 有效期(秒) */
|
||||
export const tokenPairSchema = z.object({
|
||||
accessToken: z.string().min(1, '访问令牌不能为空'),
|
||||
refreshToken: z.string().min(1, '刷新令牌不能为空'),
|
||||
expiresIn: z.number().int('有效期必须是整数秒').positive('有效期必须为正整数秒'),
|
||||
});
|
||||
|
||||
export type TokenPair = z.infer<typeof tokenPairSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /me 权限上下文
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 六类数据范围(PRD §5.4.3「数据范围」、技术架构 §6.2) */
|
||||
export const DATA_SCOPE_TYPES = [
|
||||
'all',
|
||||
'department_and_children',
|
||||
'department',
|
||||
'customer_groups',
|
||||
'owned_customers',
|
||||
'created_by_me',
|
||||
] as const;
|
||||
|
||||
export const dataScopeTypeSchema = z.enum(DATA_SCOPE_TYPES, {
|
||||
errorMap: () => ({ message: '数据范围类型不合法' }),
|
||||
});
|
||||
|
||||
export type DataScopeType = z.infer<typeof dataScopeTypeSchema>;
|
||||
|
||||
/** 数据范围解析结果:customerIds 仅「指定客户组 / 负责客户」等范围需要返回 */
|
||||
export const dataScopeSchema = z.object({
|
||||
type: dataScopeTypeSchema,
|
||||
customerIds: z.array(ulidSchema).optional(),
|
||||
});
|
||||
|
||||
export type DataScope = z.infer<typeof dataScopeSchema>;
|
||||
|
||||
/** GET /me 响应:当前用户、角色、权限点集合、数据范围、是否需强制改密 */
|
||||
export const meResponseSchema = z.object({
|
||||
user: userSummarySchema.extend({
|
||||
phone: phoneSchema.optional(),
|
||||
}),
|
||||
roles: z.array(roleSummarySchema),
|
||||
permissions: z.array(z.string().min(1, '权限点不能为空')),
|
||||
dataScope: dataScopeSchema,
|
||||
mustChangePassword: z.boolean(),
|
||||
});
|
||||
|
||||
export type MeResponse = z.infer<typeof meResponseSchema>;
|
||||
|
||||
/**
|
||||
* 登录成功响应:令牌对 + /me 同源上下文(登录一次即可拿到用户/角色/权限/数据范围),
|
||||
* 前端无需再二次拉取 /me。
|
||||
*/
|
||||
export const loginResponseSchema = tokenPairSchema.extend({
|
||||
me: meResponseSchema,
|
||||
});
|
||||
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
||||
Reference in new issue
Block a user