/** * 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; // --------------------------------------------------------------------------- // 部门 // --------------------------------------------------------------------------- /** 部门状态:停用后不出现在新建 / 编辑用户的部门选择中,历史数据保留 */ export const DEPARTMENT_STATUSES = ['active', 'disabled'] as const; export const departmentStatusSchema = z.enum(DEPARTMENT_STATUSES, { errorMap: () => ({ message: '部门状态只能是 active 或 disabled' }), }); export type DepartmentStatus = z.infer; /** 部门名称:1–20 个字符(同级唯一由服务端校验) */ export const departmentNameSchema = z .string() .trim() .min(1, '请输入部门名称') .max(20, '部门名称最多 20 个字符'); /** * 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH)。 * memberCount 含下级成员,directMemberCount 仅本部门直属成员。 */ 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(), status: departmentStatusSchema, sortOrder: z.number().int('排序必须是整数').min(0, '排序不能为负数'), memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'), directMemberCount: z.number().int('直属人数必须是整数').nonnegative('直属人数不能为负数'), createdAt: isoDateTimeSchema, }); export type DepartmentNode = z.infer; /** 新建部门 / 小组:parentId 为 null 建一级部门,否则在该部门下建小组 */ export const createDepartmentInputSchema = z .object({ name: departmentNameSchema, parentId: ulidSchema.nullable().default(null), leaderUserId: ulidSchema.nullable().optional(), sortOrder: z.number().int().min(0).max(9999).optional(), }) .strict(); export type CreateDepartmentInput = z.infer; /** 更新部门:只允许改名称 / 负责人 / 排序;上级关系创建后不可移动 */ export const updateDepartmentInputSchema = z .object({ name: departmentNameSchema.optional(), leaderUserId: ulidSchema.nullable().optional(), sortOrder: z.number().int().min(0).max(9999).optional(), }) .strict(); export type UpdateDepartmentInput = z.infer; /** 部门树查询:管理页需要包含已停用部门,普通选择器默认只看启用 */ export const departmentTreeQuerySchema = z.object({ include_disabled: z .enum(['true', 'false']) .default('false') .transform((value) => value === 'true'), }); export type DepartmentTreeQuery = z.infer; // --------------------------------------------------------------------------- // 用户 // --------------------------------------------------------------------------- /** 用户状态(active / disabled) */ export const userStatusSchema = z.enum(USER_STATUSES, { errorMap: () => ({ message: '用户状态只能是 active 或 disabled' }), }); export type UserStatus = z.infer; /** 用户摘要:列表项 / 详情基础部分;**永不含手机号全号** */ 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; /** * 用户详情 = 摘要 + 管理字段。 * `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; /** 用户列表筛选状态:默认 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; /** 用户列表排序字段(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; /** 排序方向:asc / desc,默认 desc */ export const userListOrderSchema = z.enum(['asc', 'desc'], { errorMap: () => ({ message: '排序方向只能是 asc 或 desc' }), }); export type UserListOrder = z.infer; /** * 用户列表 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; /** 新建用户入参(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; /** 更新用户入参: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; /** 停用用户入参:可填写原因,可指定客户转交接收人 */ export const disableUserInputSchema = z .object({ reason: remarkSchema, transferToUserId: ulidSchema.optional(), }) .strict(); export type DisableUserInput = z.infer; /** * 客户转交入参(占位):客户模块 A3 落地前,后端只校验结构并返回 42201; * A3 完成后customerIds 对应客户负责人批量转交。 */ export const transferCustomersInputSchema = z .object({ customerIds: z.array(ulidSchema).min(1, '至少选择一个客户'), toUserId: ulidSchema, }) .strict(); export type TransferCustomersInput = z.infer; // --------------------------------------------------------------------------- // 认证与会话 // --------------------------------------------------------------------------- /** 登录入参(账号密码登录;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; /** 刷新 access token 入参(refresh 旋转,旧 refresh 立即失效) */ export const refreshInputSchema = z .object({ refreshToken: z.string().min(1, '刷新令牌不能为空'), }) .strict(); export type RefreshInput = z.infer; /** 登出入参:refreshToken 可选(无法提供时后端按当前会话吊销) */ export const logoutInputSchema = z .object({ refreshToken: z.string().min(1, '刷新令牌不能为空').optional(), }) .strict(); export type LogoutInput = z.infer; /** 修改密码入参:newPassword 与新建用户共用同一密码策略 */ export const changePasswordInputSchema = z .object({ currentPassword: z.string().min(1, '请输入当前密码'), newPassword: passwordSchema, }) .strict(); export type ChangePasswordInput = z.infer; /** 令牌对: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; // --------------------------------------------------------------------------- // /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; /** * 数据范围(A2-BE-04): * - `type`:六类范围之一; * - `targetIds`:角色配置时指定的授权对象(当前仅「指定客户组」用客户组 id,A3 前可为空); * - `customerIds`:解析后的可见客户集合(由 CustomerDirectory 端口解析,A3 前为空数组)。 * * 角色详情返回角色声明的范围(带 targetIds);`/me` 返回多角色并集解析结果(带 customerIds)。 */ export const dataScopeSchema = z.object({ type: dataScopeTypeSchema, targetIds: z.array(ulidSchema).optional(), customerIds: z.array(ulidSchema).optional(), }); export type DataScope = z.infer; /** * GET /me 响应:当前用户、角色、权限点集合、数据范围(多角色并集)、是否需强制改密。 * 超管的 dataScopes 固定为 `[{ type: 'all' }]`;其余角色按六类范围并集解析。 */ export const meResponseSchema = z.object({ user: userSummarySchema.extend({ phone: phoneSchema.optional(), }), roles: z.array(roleSummarySchema), permissions: z.array(z.string().min(1, '权限点不能为空')), dataScopes: z.array(dataScopeSchema).min(1, '至少返回一个数据范围'), mustChangePassword: z.boolean(), }); export type MeResponse = z.infer; /** * 登录成功响应:令牌对 + /me 同源上下文(登录一次即可拿到用户/角色/权限/数据范围), * 前端无需再二次拉取 /me。 */ export const loginResponseSchema = tokenPairSchema.extend({ me: meResponseSchema, }); export type LoginResponse = z.infer;