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

@@ -0,0 +1,597 @@
import { describe, expect, it } from 'vitest';
import { ulid } from '../ulid.js';
import {
accountSchema,
changePasswordInputSchema,
createUserInputSchema,
DATA_SCOPE_TYPES,
dataScopeSchema,
departmentNodeSchema,
disableUserInputSchema,
loginInputSchema,
loginResponseSchema,
logoutInputSchema,
MAX_DEPARTMENT_DEPTH,
meResponseSchema,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
passwordSchema,
phoneSchema,
refreshInputSchema,
roleSummarySchema,
tokenPairSchema,
transferCustomersInputSchema,
updateUserInputSchema,
USER_LIST_SORTS,
USER_LIST_STATUSES,
USER_STATUSES,
userDetailSchema,
userListQuerySchema,
userStatusSchema,
userSummarySchema,
} from './identity.js';
const ISO = '2026-11-02T08:00:00.000Z';
function makeRole(): Record<string, unknown> {
return { id: ulid(), code: 'super_admin', name: '超级管理员', isSystem: true };
}
function makeSummary(): Record<string, unknown> {
return {
id: ulid(),
name: '张三',
phoneMasked: '138****8000',
account: 'zhangsan',
departmentId: ulid(),
departmentName: '运营部',
groupId: null,
groupName: null,
roles: [makeRole()],
status: 'active',
lastLoginAt: null,
createdAt: ISO,
};
}
function makeDetail(): Record<string, unknown> {
return {
...makeSummary(),
phone: '13800138000',
mustChangePassword: false,
updatedAt: ISO,
lastLoginIp: '10.0.0.8',
disabledAt: null,
};
}
function makeMe(): Record<string, unknown> {
return {
user: { ...makeSummary() },
roles: [makeRole()],
permissions: ['user:view', 'user:manage'],
dataScope: { type: 'department_and_children' },
mustChangePassword: false,
};
}
function makeLoginResponse(): Record<string, unknown> {
return {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 900,
me: makeMe(),
};
}
function makeCreateInput(): Record<string, unknown> {
return {
name: '李四',
phone: '13900139000',
account: 'lisi.001',
password: 'lisi2026',
departmentId: ulid(),
roleIds: [ulid(), ulid()],
remark: '新入职',
};
}
describe('identity 常量', () => {
it('用户状态只有 active / disabled', () => {
expect(USER_STATUSES).toEqual(['active', 'disabled']);
expect(USER_LIST_STATUSES).toEqual(['all', 'active', 'disabled']);
});
it('密码长度与部门层级常量固定', () => {
expect(PASSWORD_MIN_LENGTH).toBe(8);
expect(PASSWORD_MAX_LENGTH).toBe(64);
expect(MAX_DEPARTMENT_DEPTH).toBe(2);
});
it('数据范围覆盖六类', () => {
expect(DATA_SCOPE_TYPES).toEqual([
'all',
'department_and_children',
'department',
'customer_groups',
'owned_customers',
'created_by_me',
]);
});
});
describe('基础字段 schema', () => {
it('phoneSchema 只接受 11 位大陆手机号', () => {
expect(phoneSchema.parse('13800138000')).toBe('13800138000');
expect(phoneSchema.parse('19912345678')).toBe('19912345678');
expect(() => phoneSchema.parse('12800138000')).toThrow();
expect(() => phoneSchema.parse('1380013800')).toThrow();
expect(() => phoneSchema.parse('138001380001')).toThrow();
expect(() => phoneSchema.parse('13800138abc')).toThrow();
});
it('accountSchema 接受邮箱本地部分 / 工号风格,拒绝非法开头与字符', () => {
expect(accountSchema.parse('lisi.001')).toBe('lisi.001');
expect(accountSchema.parse('dev_user-01')).toBe('dev_user-01');
expect(accountSchema.parse('lisi2026')).toBe('lisi2026');
expect(() => accountSchema.parse('ab')).toThrow();
expect(() => accountSchema.parse('.lisi')).toThrow();
expect(() => accountSchema.parse('李四')).toThrow();
expect(() => accountSchema.parse('li si')).toThrow();
expect(() => accountSchema.parse(`a${'b'.repeat(64)}`)).toThrow();
});
it('passwordSchema 要求 8–64 位且同时含字母与数字', () => {
expect(passwordSchema.parse('lisi2026')).toBe('lisi2026');
expect(passwordSchema.parse('Passw0rd-very-long')).toBe('Passw0rd-very-long');
expect(passwordSchema.parse(`a1${'x'.repeat(PASSWORD_MAX_LENGTH - 2)}`)).toHaveLength(64);
expect(() => passwordSchema.parse('abc1234')).toThrow();
expect(() => passwordSchema.parse('abcdefgh')).toThrow();
expect(() => passwordSchema.parse('12345678')).toThrow();
expect(() => passwordSchema.parse(`a1${'x'.repeat(PASSWORD_MAX_LENGTH - 1)}`)).toThrow();
});
});
describe('roleSummarySchema', () => {
it('接受完整角色摘要(含描述)', () => {
const parsed = roleSummarySchema.parse({
...makeRole(),
description: '内置超级管理员',
});
expect(parsed.isSystem).toBe(true);
expect(parsed.description).toBe('内置超级管理员');
});
it('description 可省略', () => {
expect(roleSummarySchema.parse(makeRole()).description).toBeUndefined();
});
it('拒绝非法 ULID / 空名称 / 缺失 isSystem', () => {
expect(() => roleSummarySchema.parse({ ...makeRole(), id: 'not-ulid' })).toThrow();
expect(() => roleSummarySchema.parse({ ...makeRole(), name: '' })).toThrow();
expect(() => roleSummarySchema.parse({ ...makeRole(), code: '' })).toThrow();
const { isSystem: _isSystem, ...withoutSystem } = makeRole();
expect(() => roleSummarySchema.parse(withoutSystem)).toThrow();
});
});
describe('departmentNodeSchema', () => {
it('接受一级部门(parentId / leader 为 null)', () => {
const parsed = departmentNodeSchema.parse({
id: ulid(),
name: '运营中心',
parentId: null,
leader: null,
memberCount: 0,
createdAt: ISO,
});
expect(parsed.parentId).toBeNull();
expect(parsed.memberCount).toBe(0);
});
it('接受二级小组(含负责人)', () => {
const parsed = departmentNodeSchema.parse({
id: ulid(),
name: '投放一组',
parentId: ulid(),
leader: { id: ulid(), name: '王五' },
memberCount: 12,
createdAt: ISO,
});
expect(parsed.leader?.name).toBe('王五');
});
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名', () => {
const base = {
id: ulid(),
name: '运营中心',
parentId: null,
leader: null,
memberCount: 3,
createdAt: ISO,
};
expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow();
expect(() => departmentNodeSchema.parse({ ...base, memberCount: 1.5 })).toThrow();
expect(() => departmentNodeSchema.parse({ ...base, parentId: 'bad' })).toThrow();
expect(() =>
departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }),
).toThrow();
});
});
describe('userStatusSchema', () => {
it('接受 active / disabled', () => {
expect(userStatusSchema.parse('active')).toBe('active');
expect(userStatusSchema.parse('disabled')).toBe('disabled');
});
it('拒绝未知状态并给中文提示', () => {
expect(() => userStatusSchema.parse('pending')).toThrow('用户状态只能是 active 或 disabled');
expect(() => userStatusSchema.parse('ACTIVE')).toThrow();
expect(() => userStatusSchema.parse(1)).toThrow();
});
});
describe('userSummarySchema', () => {
it('接受摘要,且不含手机号全号字段', () => {
const parsed = userSummarySchema.parse(makeSummary());
expect(parsed.status).toBe('active');
expect(parsed.lastLoginAt).toBeNull();
expect(Object.hasOwn(parsed, 'phone')).toBe(false);
});
it('即使传入全号也会被剥离(列表永不含全号)', () => {
const parsed = userSummarySchema.parse({ ...makeSummary(), phone: '13800138000' });
expect(Object.hasOwn(parsed, 'phone')).toBe(false);
});
it('拒绝非法状态 / 非法部门 ID / 无时区时间', () => {
expect(() => userSummarySchema.parse({ ...makeSummary(), status: 'unknown' })).toThrow();
expect(() => userSummarySchema.parse({ ...makeSummary(), departmentId: 'x' })).toThrow();
expect(() =>
userSummarySchema.parse({ ...makeSummary(), createdAt: '2026-11-02 08:00:00' }),
).toThrow();
});
});
describe('userDetailSchema', () => {
it('接受含手机号全号的详情', () => {
const parsed = userDetailSchema.parse(makeDetail());
expect(parsed.phone).toBe('13800138000');
expect(parsed.lastLoginIp).toBe('10.0.0.8');
expect(parsed.disabledAt).toBeNull();
});
it('无 user:manage 时不返回 phone(undefined),可选字段可省略', () => {
const { phone: _phone, lastLoginIp: _ip, disabledAt: _disabledAt, ...detail } = makeDetail();
const parsed = userDetailSchema.parse(detail);
expect(parsed.phone).toBeUndefined();
expect(parsed.lastLoginIp).toBeUndefined();
expect(parsed.disabledAt).toBeUndefined();
});
it('拒绝非法手机号 / 缺失 mustChangePassword / 非法 updatedAt', () => {
expect(() => userDetailSchema.parse({ ...makeDetail(), phone: '12345' })).toThrow();
const { mustChangePassword: _must, ...withoutFlag } = makeDetail();
expect(() => userDetailSchema.parse(withoutFlag)).toThrow();
expect(() => userDetailSchema.parse({ ...makeDetail(), updatedAt: 'yesterday' })).toThrow();
});
});
describe('userListQuerySchema', () => {
it('缺省 limit=20、status=active、按最近登录倒序', () => {
expect(userListQuerySchema.parse({})).toEqual({
limit: 20,
status: 'active',
sort: 'last_login',
order: 'desc',
});
});
it('接受 snake_case 筛选参数并强制转换 limit', () => {
const parsed = userListQuerySchema.parse({
limit: '50',
cursor: 'cursor-2',
keyword: ' 张三 ',
department_id: ulid(),
role_id: ulid(),
status: 'disabled',
sort: 'created_at',
order: 'asc',
});
expect(parsed.limit).toBe(50);
expect(parsed.keyword).toBe('张三');
expect(parsed.status).toBe('disabled');
expect(parsed.sort).toBe('created_at');
expect(parsed.order).toBe('asc');
});
it('接受 status=all(查看含停用),排序字段白名单固定', () => {
expect(userListQuerySchema.parse({ status: 'all' }).status).toBe('all');
expect(USER_LIST_SORTS).toEqual(['last_login', 'created_at']);
});
it('拒绝未知状态 / 超长关键词 / 非法 ID / 越界 limit', () => {
expect(() => userListQuerySchema.parse({ status: 'pending' })).toThrow();
expect(() => userListQuerySchema.parse({ keyword: 'x'.repeat(65) })).toThrow();
expect(() => userListQuerySchema.parse({ department_id: 'bad' })).toThrow();
expect(() => userListQuerySchema.parse({ role_id: 'bad' })).toThrow();
expect(() => userListQuerySchema.parse({ limit: 0 })).toThrow();
expect(() => userListQuerySchema.parse({ limit: '101' })).toThrow();
});
it('拒绝非法排序字段与方向', () => {
expect(() => userListQuerySchema.parse({ sort: 'name' })).toThrow(
'排序字段只能是 last_login 或 created_at',
);
expect(() => userListQuerySchema.parse({ order: 'up' })).toThrow('排序方向只能是 asc 或 desc');
});
});
describe('createUserInputSchema', () => {
it('接受完整创建入参(含备注)', () => {
const parsed = createUserInputSchema.parse(makeCreateInput());
expect(parsed.roleIds).toHaveLength(2);
expect(parsed.remark).toBe('新入职');
});
it('备注可省略', () => {
const { remark: _remark, ...input } = makeCreateInput();
expect(createUserInputSchema.parse(input).remark).toBeUndefined();
});
it('password 分支:弱密码被拒', () => {
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), password: 'abcd123' }),
).toThrow();
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), password: 'abcdefgh' }),
).toThrow();
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), password: '12345678' }),
).toThrow();
expect(() =>
createUserInputSchema.parse({
...makeCreateInput(),
password: `a1${'x'.repeat(PASSWORD_MAX_LENGTH - 1)}`,
}),
).toThrow();
});
it('phone 分支:非 11 位手机号被拒', () => {
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), phone: '1380013800' }),
).toThrow();
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), phone: '23800138000' }),
).toThrow();
});
it('roleIds 分支:空数组 / 非 ULID 被拒', () => {
expect(() => createUserInputSchema.parse({ ...makeCreateInput(), roleIds: [] })).toThrow(
'至少分配一个角色',
);
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), roleIds: ['not-ulid'] }),
).toThrow();
});
it('name / account / departmentId / remark 边界', () => {
expect(() => createUserInputSchema.parse({ ...makeCreateInput(), name: '' })).toThrow();
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), name: 'x'.repeat(21) }),
).toThrow();
expect(() => createUserInputSchema.parse({ ...makeCreateInput(), account: 'ab' })).toThrow();
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), departmentId: 'bad' }),
).toThrow();
expect(() =>
createUserInputSchema.parse({ ...makeCreateInput(), remark: 'x'.repeat(201) }),
).toThrow();
});
it('严格模式拒绝未知字段(含 status)', () => {
expect(() => createUserInputSchema.parse({ ...makeCreateInput(), status: 'active' })).toThrow();
});
});
describe('updateUserInputSchema', () => {
it('接受部分字段更新', () => {
const parsed = updateUserInputSchema.parse({
name: ' 李四四 ',
phone: '13900139000',
departmentId: ulid(),
roleIds: [ulid()],
remark: '调岗',
});
expect(parsed.name).toBe('李四四');
expect(parsed.roleIds).toHaveLength(1);
});
it('接受空对象(后端按需拒绝空更新)', () => {
expect(updateUserInputSchema.parse({})).toEqual({});
});
it('拒绝对 account / status 的修改', () => {
expect(() => updateUserInputSchema.parse({ account: 'zhangsan' })).toThrow();
expect(() => updateUserInputSchema.parse({ status: 'disabled' })).toThrow();
});
it('拒绝非法手机号与空 roleIds', () => {
expect(() => updateUserInputSchema.parse({ phone: '111' })).toThrow();
expect(() => updateUserInputSchema.parse({ roleIds: [] })).toThrow();
});
});
describe('disableUserInputSchema', () => {
it('接受停用原因与转交人', () => {
const parsed = disableUserInputSchema.parse({
reason: '离职',
transferToUserId: ulid(),
});
expect(parsed.reason).toBe('离职');
expect(parsed.transferToUserId).toHaveLength(26);
});
it('全部字段可省略', () => {
expect(disableUserInputSchema.parse({})).toEqual({});
});
it('拒绝超长原因与非法转交人 ID', () => {
expect(() => disableUserInputSchema.parse({ reason: 'x'.repeat(201) })).toThrow();
expect(() => disableUserInputSchema.parse({ transferToUserId: 'bad' })).toThrow();
});
});
describe('transferCustomersInputSchema', () => {
it('接受至少一个客户与接收人', () => {
const parsed = transferCustomersInputSchema.parse({
customerIds: [ulid(), ulid()],
toUserId: ulid(),
});
expect(parsed.customerIds).toHaveLength(2);
});
it('拒绝空客户列表与非法接收人', () => {
expect(() => transferCustomersInputSchema.parse({ customerIds: [], toUserId: ulid() })).toThrow(
'至少选择一个客户',
);
expect(() =>
transferCustomersInputSchema.parse({ customerIds: [ulid()], toUserId: 'bad' }),
).toThrow();
});
});
describe('认证 schema', () => {
it('loginInputSchema 接受账号密码,拒绝空值 / 未知字段', () => {
expect(loginInputSchema.parse({ account: 'zhangsan', password: 'lisi2026' })).toEqual({
account: 'zhangsan',
password: 'lisi2026',
});
expect(() => loginInputSchema.parse({ account: 'ab', password: 'lisi2026' })).toThrow();
expect(() => loginInputSchema.parse({ account: 'zhangsan', password: '' })).toThrow();
expect(() =>
loginInputSchema.parse({ account: 'zhangsan', password: 'lisi2026', remember: true }),
).toThrow();
});
it('refreshInputSchema 要求非空 refreshToken', () => {
expect(refreshInputSchema.parse({ refreshToken: 'refresh-token' })).toEqual({
refreshToken: 'refresh-token',
});
expect(() => refreshInputSchema.parse({ refreshToken: '' })).toThrow();
expect(() => refreshInputSchema.parse({})).toThrow();
});
it('logoutInputSchema 的 refreshToken 可选', () => {
expect(logoutInputSchema.parse({})).toEqual({});
expect(logoutInputSchema.parse({ refreshToken: 'token' })).toEqual({ refreshToken: 'token' });
expect(() => logoutInputSchema.parse({ refreshToken: '' })).toThrow();
expect(() => logoutInputSchema.parse({ refreshToken: 123 })).toThrow();
});
it('changePasswordInputSchema 复用密码策略', () => {
expect(
changePasswordInputSchema.parse({
currentPassword: 'oldPass1',
newPassword: 'newPass2026',
}).newPassword,
).toBe('newPass2026');
expect(() =>
changePasswordInputSchema.parse({ currentPassword: '', newPassword: 'newPass2026' }),
).toThrow();
expect(() =>
changePasswordInputSchema.parse({ currentPassword: 'oldPass1', newPassword: 'abcdefgh' }),
).toThrow();
});
it('tokenPairSchema 校验有效期为正整数秒', () => {
expect(
tokenPairSchema.parse({
accessToken: 'access',
refreshToken: 'refresh',
expiresIn: 900,
}).expiresIn,
).toBe(900);
expect(() =>
tokenPairSchema.parse({ accessToken: 'a', refreshToken: 'r', expiresIn: 0 }),
).toThrow();
expect(() =>
tokenPairSchema.parse({ accessToken: 'a', refreshToken: 'r', expiresIn: -1 }),
).toThrow();
expect(() =>
tokenPairSchema.parse({ accessToken: 'a', refreshToken: 'r', expiresIn: 1.5 }),
).toThrow();
expect(() =>
tokenPairSchema.parse({ accessToken: '', refreshToken: 'r', expiresIn: 900 }),
).toThrow();
});
});
describe('dataScopeSchema / meResponseSchema', () => {
it('dataScopeSchema 接受六类范围并允许可选 customerIds', () => {
for (const type of DATA_SCOPE_TYPES) {
expect(dataScopeSchema.parse({ type }).type).toBe(type);
}
const withCustomers = dataScopeSchema.parse({
type: 'customer_groups',
customerIds: [ulid()],
});
expect(withCustomers.customerIds).toHaveLength(1);
});
it('dataScopeSchema 拒绝未知范围类型与非法客户 ID', () => {
expect(() => dataScopeSchema.parse({ type: 'own' })).toThrow('数据范围类型不合法');
expect(() => dataScopeSchema.parse({ type: 'all', customerIds: ['bad'] })).toThrow();
});
it('meResponseSchema 接受完整上下文(user 含手机号全号)', () => {
const me = makeMe();
me.user = { ...makeSummary(), phone: '13800138000' };
const parsed = meResponseSchema.parse(me);
expect(parsed.user.phone).toBe('13800138000');
expect(parsed.permissions).toEqual(['user:view', 'user:manage']);
expect(parsed.dataScope.type).toBe('department_and_children');
expect(parsed.mustChangePassword).toBe(false);
});
it('meResponseSchema 允许 user 不含手机号、dataScope 不含 customerIds', () => {
const parsed = meResponseSchema.parse(makeMe());
expect(parsed.user.phone).toBeUndefined();
expect(parsed.dataScope.customerIds).toBeUndefined();
});
it('meResponseSchema 拒绝缺失 mustChangePassword / 非法权限点 / 非法数据范围', () => {
const { mustChangePassword: _must, ...withoutFlag } = makeMe();
expect(() => meResponseSchema.parse(withoutFlag)).toThrow();
expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [''] })).toThrow();
expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [1] })).toThrow();
expect(() =>
meResponseSchema.parse({ ...makeMe(), dataScope: { type: 'everything' } }),
).toThrow();
});
});
describe('loginResponseSchema', () => {
it('登录一次返回令牌对 + /me 上下文(前端无需二次拉 /me)', () => {
const parsed = loginResponseSchema.parse(makeLoginResponse());
expect(parsed.accessToken).toBe('access-token');
expect(parsed.refreshToken).toBe('refresh-token');
expect(parsed.expiresIn).toBe(900);
expect(parsed.me.permissions).toEqual(['user:view', 'user:manage']);
expect(parsed.me.user.phone).toBeUndefined();
});
it('拒绝缺失 me / 非法 me 数据范围 / 非正整数 expiresIn', () => {
const { me: _me, ...withoutMe } = makeLoginResponse();
expect(() => loginResponseSchema.parse(withoutMe)).toThrow();
expect(() =>
loginResponseSchema.parse({
...makeLoginResponse(),
me: { ...makeMe(), dataScope: { type: 'unknown' } },
}),
).toThrow();
expect(() => loginResponseSchema.parse({ ...makeLoginResponse(), expiresIn: 0 })).toThrow();
});
});
+339
View File
@@ -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>;