Files
lingxi/packages/shared/src/schemas/identity.ts
T
Stev_Wang 6661c42e84
ci / 质量门禁 (push) Canceled after 0s
feat(identity): 补齐部门管理(两级组织树、停用启用)
背景:
- A1 只实现了部门树读取与新建 / 改名接口,没有部门管理入口,
  负责人、排序、停用状态都无法维护;
- 用户必须关联部门,部门无法维护会让 A2 的数据范围缺少组织基础。

改动:
- shared:DepartmentNode 增加 status / sortOrder / directMemberCount;
  新增 createDepartmentInput / updateDepartmentInput / departmentTreeQuery 契约;
- api:GET /departments/tree?include_disabled;POST/PATCH /departments
  (名称 / 负责人 / 排序);POST /departments/{id}/disable|enable;
  只停用不删除,有在职成员 / 启用子部门 / 下级在职成员时禁止停用;
  新建 / 编辑用户校验部门必须启用;@Audit 与 OpenAPI 同步;
- web:管理中心新增「部门管理」/system/departments,左两级组织树 +
  右详情 / 内联编辑 + 新建部门/小组 + 停用启用二次确认;显示已停用、
  ?id= 选中可恢复、无 user:manage 只读;用户页部门选择仍只取启用部门;
- 测试:shared 契约、api 单测 + int、web 树/表单/接口单测同步。

影响面:
- 新增部门写接口与页面;GET tree 默认仍只返回启用部门,对用户页无破坏;
- 权限复用 user:view / user:manage,不新增权限点;
- 无数据库迁移(status / leader_user_id / sort_order 列已存在)。

回滚方式:
- 回滚本提交即移除部门管理页与写接口,保留原只读树;数据库无需回滚。

验证证据:
- pnpm format:check / lint / typecheck 全绿;
- pnpm test:shared 121 / api 90 / web 120;test:int 4 files / 33 tests;
- pnpm openapi:diff:无破坏性变更;
- 真实 API + 浏览器(jiang.lan):新建一级部门 / 小组、改名、停用空小组、
  重名 40903、树/详情/停用确认均正常;
  截图 design/a1-ui-v1/shots/impl/70-departments.png。
2026-11-03 15:01:07 +08:00

406 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>;
// ---------------------------------------------------------------------------
// 部门
// ---------------------------------------------------------------------------
/** 部门状态:停用后不出现在新建 / 编辑用户的部门选择中,历史数据保留 */
export const DEPARTMENT_STATUSES = ['active', 'disabled'] as const;
export const departmentStatusSchema = z.enum(DEPARTMENT_STATUSES, {
errorMap: () => ({ message: '部门状态只能是 active 或 disabled' }),
});
export type DepartmentStatus = z.infer<typeof departmentStatusSchema>;
/** 部门名称: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<typeof departmentNodeSchema>;
/** 新建部门 / 小组: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<typeof createDepartmentInputSchema>;
/** 更新部门:只允许改名称 / 负责人 / 排序;上级关系创建后不可移动 */
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<typeof updateDepartmentInputSchema>;
/** 部门树查询:管理页需要包含已停用部门,普通选择器默认只看启用 */
export const departmentTreeQuerySchema = z.object({
include_disabled: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
});
export type DepartmentTreeQuery = z.infer<typeof departmentTreeQuerySchema>;
// ---------------------------------------------------------------------------
// 用户
// ---------------------------------------------------------------------------
/** 用户状态(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>;
/**
* 数据范围(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<typeof dataScopeSchema>;
/**
* 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<typeof meResponseSchema>;
/**
* 登录成功响应:令牌对 + /me 同源上下文(登录一次即可拿到用户/角色/权限/数据范围),
* 前端无需再二次拉取 /me。
*/
export const loginResponseSchema = tokenPairSchema.extend({
me: meResponseSchema,
});
export type LoginResponse = z.infer<typeof loginResponseSchema>;