feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。 改动: - shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。 - prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。 - api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。 - web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。 - 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。 验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
This commit is contained in:
1 parent
f97f97b0cd
commit
61894df401
80 files changed
+14380
-237
No files matched your search
@@ -71,7 +71,7 @@ function makeMe(): Record<string, unknown> {
|
||||
user: { ...makeSummary() },
|
||||
roles: [makeRole()],
|
||||
permissions: ['user:view', 'user:manage'],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
dataScopes: [{ type: 'department_and_children' }],
|
||||
mustChangePassword: false,
|
||||
};
|
||||
}
|
||||
@@ -530,45 +530,56 @@ describe('认证 schema', () => {
|
||||
});
|
||||
|
||||
describe('dataScopeSchema / meResponseSchema', () => {
|
||||
it('dataScopeSchema 接受六类范围并允许可选 customerIds', () => {
|
||||
it('dataScopeSchema 接受六类范围,并允许可选 targetIds / customerIds', () => {
|
||||
for (const type of DATA_SCOPE_TYPES) {
|
||||
expect(dataScopeSchema.parse({ type }).type).toBe(type);
|
||||
}
|
||||
const withCustomers = dataScopeSchema.parse({
|
||||
type: 'customer_groups',
|
||||
targetIds: [ulid()],
|
||||
customerIds: [ulid()],
|
||||
});
|
||||
expect(withCustomers.targetIds).toHaveLength(1);
|
||||
expect(withCustomers.customerIds).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dataScopeSchema 拒绝未知范围类型与非法客户 ID', () => {
|
||||
expect(() => dataScopeSchema.parse({ type: 'own' })).toThrow('数据范围类型不合法');
|
||||
expect(() => dataScopeSchema.parse({ type: 'all', customerIds: ['bad'] })).toThrow();
|
||||
expect(() => dataScopeSchema.parse({ type: 'customer_groups', targetIds: ['bad'] })).toThrow();
|
||||
});
|
||||
|
||||
it('meResponseSchema 接受完整上下文(user 含手机号全号)', () => {
|
||||
it('meResponseSchema 接受完整上下文(user 含手机号全号、dataScopes 多范围)', () => {
|
||||
const me = makeMe();
|
||||
me.user = { ...makeSummary(), phone: '13800138000' };
|
||||
me.dataScopes = [
|
||||
{ type: 'owned_customers', customerIds: [] },
|
||||
{ type: 'department', customerIds: [] },
|
||||
];
|
||||
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.dataScopes).toHaveLength(2);
|
||||
expect(parsed.dataScopes[0]?.type).toBe('owned_customers');
|
||||
expect(parsed.dataScopes[0]?.customerIds).toEqual([]);
|
||||
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 超管口径:dataScopes 为 [{ type: all }]', () => {
|
||||
const parsed = meResponseSchema.parse({ ...makeMe(), dataScopes: [{ type: 'all' }] });
|
||||
expect(parsed.dataScopes).toEqual([{ type: 'all' }]);
|
||||
});
|
||||
|
||||
it('meResponseSchema 拒绝缺失 mustChangePassword / 非法权限点 / 非法数据范围', () => {
|
||||
it('meResponseSchema 拒绝缺失 dataScopes / 空数组 / 非法权限点 / 非法数据范围', () => {
|
||||
const { mustChangePassword: _must, ...withoutFlag } = makeMe();
|
||||
expect(() => meResponseSchema.parse(withoutFlag)).toThrow();
|
||||
const { dataScopes: _scopes, ...withoutScopes } = makeMe();
|
||||
expect(() => meResponseSchema.parse(withoutScopes)).toThrow();
|
||||
expect(() => meResponseSchema.parse({ ...makeMe(), dataScopes: [] })).toThrow();
|
||||
expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [''] })).toThrow();
|
||||
expect(() => meResponseSchema.parse({ ...makeMe(), permissions: [1] })).toThrow();
|
||||
expect(() =>
|
||||
meResponseSchema.parse({ ...makeMe(), dataScope: { type: 'everything' } }),
|
||||
meResponseSchema.parse({ ...makeMe(), dataScopes: [{ type: 'everything' }] }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -589,7 +600,7 @@ describe('loginResponseSchema', () => {
|
||||
expect(() =>
|
||||
loginResponseSchema.parse({
|
||||
...makeLoginResponse(),
|
||||
me: { ...makeMe(), dataScope: { type: 'unknown' } },
|
||||
me: { ...makeMe(), dataScopes: [{ type: 'unknown' }] },
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() => loginResponseSchema.parse({ ...makeLoginResponse(), expiresIn: 0 })).toThrow();
|
||||
|
||||
@@ -307,22 +307,33 @@ export const dataScopeTypeSchema = z.enum(DATA_SCOPE_TYPES, {
|
||||
|
||||
export type DataScopeType = z.infer<typeof dataScopeTypeSchema>;
|
||||
|
||||
/** 数据范围解析结果:customerIds 仅「指定客户组 / 负责客户」等范围需要返回 */
|
||||
/**
|
||||
* 数据范围(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 响应:当前用户、角色、权限点集合、数据范围、是否需强制改密 */
|
||||
/**
|
||||
* 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, '权限点不能为空')),
|
||||
dataScope: dataScopeSchema,
|
||||
dataScopes: z.array(dataScopeSchema).min(1, '至少返回一个数据范围'),
|
||||
mustChangePassword: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ulid } from '../ulid.js';
|
||||
import {
|
||||
createRoleInputSchema,
|
||||
permissionCodeSchema,
|
||||
permissionTreeSchema,
|
||||
roleDataScopeInputSchema,
|
||||
roleDetailSchema,
|
||||
roleListItemSchema,
|
||||
roleListQuerySchema,
|
||||
roleSaveResultSchema,
|
||||
updateRoleInputSchema,
|
||||
} from './roles.js';
|
||||
|
||||
const ISO = '2026-11-03T08:00:00.000Z';
|
||||
|
||||
function makeListItem(): Record<string, unknown> {
|
||||
return {
|
||||
id: ulid(),
|
||||
code: 'material_operator',
|
||||
name: '素材运营',
|
||||
description: '素材生产、快筛、导出与归档',
|
||||
isSystem: false,
|
||||
status: 'active',
|
||||
permissionCount: 9,
|
||||
specialPermissionCount: 0,
|
||||
userCount: 2,
|
||||
dataScope: { type: 'owned_customers' },
|
||||
createdAt: ISO,
|
||||
updatedAt: ISO,
|
||||
};
|
||||
}
|
||||
|
||||
describe('roleListItemSchema / roleDetailSchema', () => {
|
||||
it('列表项包含目录统计与数据范围', () => {
|
||||
const parsed = roleListItemSchema.parse(makeListItem());
|
||||
expect(parsed.permissionCount).toBe(9);
|
||||
expect(parsed.userCount).toBe(2);
|
||||
expect(parsed.dataScope.type).toBe('owned_customers');
|
||||
});
|
||||
|
||||
it('详情在列表项基础上含普通权限、特殊权限与绑定用户', () => {
|
||||
const detail = roleDetailSchema.parse({
|
||||
...makeListItem(),
|
||||
permissions: ['script:read', 'script:write'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
boundUsers: [
|
||||
{
|
||||
id: ulid(),
|
||||
name: '唐嘉',
|
||||
account: 'tang.jia',
|
||||
phoneMasked: '135****6641',
|
||||
departmentName: '素材一组',
|
||||
status: 'disabled',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detail.permissions).toHaveLength(2);
|
||||
expect(detail.specialPermissions).toEqual(['delivery:export']);
|
||||
expect(detail.boundUsers[0]?.phoneMasked).toBe('135****6641');
|
||||
});
|
||||
|
||||
it('拒绝负统计值 / 未登记权限点 / 非法状态', () => {
|
||||
expect(() => roleListItemSchema.parse({ ...makeListItem(), userCount: -1 })).toThrow();
|
||||
expect(() =>
|
||||
roleDetailSchema.parse({
|
||||
...makeListItem(),
|
||||
permissions: ['script:delete_all'],
|
||||
specialPermissions: [],
|
||||
boundUsers: [],
|
||||
}),
|
||||
).toThrow('未登记的权限点编码');
|
||||
expect(() => roleListItemSchema.parse({ ...makeListItem(), status: 'enabled' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('角色输入 schema', () => {
|
||||
it('新建角色:权限与特殊权限默认空,但数据范围必填(六类单选)', () => {
|
||||
expect(
|
||||
createRoleInputSchema.parse({ name: '运营', dataScope: { type: 'created_by_me' } }),
|
||||
).toEqual({ name: '运营', dataScope: { type: 'created_by_me' } });
|
||||
expect(() => createRoleInputSchema.parse({ name: '运营' })).toThrow();
|
||||
});
|
||||
|
||||
it('权限点必须已登记,且特殊权限单独字段提交', () => {
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'all' },
|
||||
permissions: ['script:delete_all'],
|
||||
}),
|
||||
).toThrow('未登记的权限点编码');
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'all' },
|
||||
permissions: ['delivery:export'],
|
||||
}),
|
||||
).toThrow('必须放入 specialPermissions');
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'all' },
|
||||
specialPermissions: ['script:read'],
|
||||
}),
|
||||
).toThrow('不能放入 specialPermissions');
|
||||
const parsed = createRoleInputSchema.parse({
|
||||
name: '运营',
|
||||
dataScope: { type: 'customer_groups', targetIds: [] },
|
||||
permissions: ['script:read'],
|
||||
specialPermissions: ['delivery:export'],
|
||||
});
|
||||
expect(parsed.permissions).toEqual(['script:read']);
|
||||
expect(parsed.specialPermissions).toEqual(['delivery:export']);
|
||||
});
|
||||
|
||||
it('未知字段被拒绝(strict)', () => {
|
||||
expect(() =>
|
||||
createRoleInputSchema.parse({ name: '运营', dataScope: { type: 'all' }, unknown: 1 }),
|
||||
).toThrow();
|
||||
expect(() => updateRoleInputSchema.parse({ copyFromRoleId: ulid() })).toThrow();
|
||||
});
|
||||
|
||||
it('更新支持 status enabled / disabled 与停用原因', () => {
|
||||
expect(updateRoleInputSchema.parse({ status: 'disabled', reason: '岗位合并' })).toEqual({
|
||||
status: 'disabled',
|
||||
reason: '岗位合并',
|
||||
});
|
||||
expect(() => updateRoleInputSchema.parse({ status: 'active' })).toThrow();
|
||||
});
|
||||
|
||||
it('权限点编码 schema 与目录同源', () => {
|
||||
expect(permissionCodeSchema.parse('role:view')).toBe('role:view');
|
||||
expect(() => permissionCodeSchema.parse('role:delete')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleDataScopeInputSchema', () => {
|
||||
it('六类范围可用;指定客户组可带 targetIds', () => {
|
||||
expect(roleDataScopeInputSchema.parse({ type: 'all' })).toEqual({ type: 'all' });
|
||||
const groups = roleDataScopeInputSchema.parse({
|
||||
type: 'customer_groups',
|
||||
targetIds: [ulid()],
|
||||
});
|
||||
expect(groups.targetIds).toHaveLength(1);
|
||||
expect(roleDataScopeInputSchema.parse({ type: 'customer_groups', targetIds: [] })).toEqual({
|
||||
type: 'customer_groups',
|
||||
targetIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('非「指定客户组」范围不允许 targetIds', () => {
|
||||
expect(() => roleDataScopeInputSchema.parse({ type: 'all', targetIds: [ulid()] })).toThrow(
|
||||
'只有「指定客户组」范围允许填写 targetIds',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleListQuerySchema', () => {
|
||||
it('默认 status=active(兼容 A1 角色多选)、sort=created_at、order=desc、limit=20', () => {
|
||||
expect(roleListQuerySchema.parse({})).toEqual({
|
||||
status: 'active',
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('白名单校验排序与状态', () => {
|
||||
expect(roleListQuerySchema.parse({ sort: 'name', order: 'asc', status: 'disabled' }).sort).toBe(
|
||||
'name',
|
||||
);
|
||||
expect(() => roleListQuerySchema.parse({ sort: 'permission_count' })).toThrow();
|
||||
expect(() => roleListQuerySchema.parse({ status: 'enabled' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permissionTreeSchema / roleSaveResultSchema', () => {
|
||||
it('权限树含模块、特殊权限与 SoD 对', () => {
|
||||
const parsed = permissionTreeSchema.parse({
|
||||
modules: [
|
||||
{
|
||||
module: 'script',
|
||||
moduleLabel: '脚本',
|
||||
sortOrder: 50,
|
||||
permissions: [
|
||||
{ code: 'script:read', name: '脚本查看', description: '', isSpecial: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
specialPermissions: [
|
||||
{ code: 'delivery:export', name: '交付导出', description: '', isSpecial: true },
|
||||
],
|
||||
sodPairs: [
|
||||
{
|
||||
id: 'script-write-review',
|
||||
label: '脚本生成 × 脚本审核',
|
||||
description: '',
|
||||
permissions: ['script:write', 'script:review'],
|
||||
},
|
||||
],
|
||||
totalPermissions: 30,
|
||||
});
|
||||
expect(parsed.modules).toHaveLength(1);
|
||||
expect(parsed.totalPermissions).toBe(30);
|
||||
});
|
||||
|
||||
it('保存结果含角色详情、影响用户数与软提示', () => {
|
||||
const result = roleSaveResultSchema.parse({
|
||||
role: {
|
||||
...makeListItem(),
|
||||
permissions: [],
|
||||
specialPermissions: [],
|
||||
boundUsers: [],
|
||||
},
|
||||
affectedUserCount: 3,
|
||||
warnings: ['角色数量已达建议上限 20'],
|
||||
});
|
||||
expect(result.affectedUserCount).toBe(3);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 角色管理共享契约(A2:角色 CRUD、权限点目录、特殊权限、六类数据范围、SoD)
|
||||
* 依据:PRD §5.4.3、技术架构 §6.3.1 / §8.7 D-07、开发计划 §6.3 / §7.2、H1 权限门口径。
|
||||
*
|
||||
* 约定:
|
||||
* - query 用 snake_case(cursor/limit/keyword/status/sort/order);
|
||||
* - 写 body 用 camelCase 且 `.strict()`;特殊权限与普通权限分两个字段提交;
|
||||
* - 权限点编码只能在 `permissions.ts` 目录内取值,且 `code` 自动生成、不可改;
|
||||
* - **数据范围:一个角色只选一种类型**(六类单选),仅 `customer_groups` 可带多个 `targetIds`;
|
||||
* 多角色之间才在 `/me` 取并集;
|
||||
* - DELETE 角色返回 204;系统角色不可删除 / 不可停用 / 不可改权限,仅显示名可改。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isPermissionCode, isSpecialPermission } from '../permissions.js';
|
||||
import { cursorPaginationQuerySchema } from './api.js';
|
||||
import { isoDateTimeSchema, ulidSchema } from './common.js';
|
||||
import { dataScopeSchema, dataScopeTypeSchema, userStatusSchema } from './identity.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础字段
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 已登记的权限点编码(未登记 → 40001,不允许角色里出现「幽灵权限」) */
|
||||
export const permissionCodeSchema = z.string().refine(isPermissionCode, '未登记的权限点编码');
|
||||
|
||||
/** 角色显示名:1–64 字符(超管也允许改显示名) */
|
||||
export const roleNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入角色名称')
|
||||
.max(64, '角色名称最多 64 个字符');
|
||||
|
||||
/** 角色说明:≤ 255 字符(与 roles.description VarChar(255) 对齐) */
|
||||
export const roleDescriptionSchema = z.string().trim().max(255, '角色说明最多 255 个字符');
|
||||
|
||||
/** 角色状态(输出口径):active 启用 / disabled 停用 */
|
||||
export const ROLE_STATUSES = ['active', 'disabled'] as const;
|
||||
|
||||
export const roleStatusSchema = z.enum(ROLE_STATUSES, {
|
||||
errorMap: () => ({ message: '角色状态只能是 active 或 disabled' }),
|
||||
});
|
||||
|
||||
export type RoleStatus = z.infer<typeof roleStatusSchema>;
|
||||
|
||||
/** 角色状态变更入参口径:enabled 启用 / disabled 停用(PATCH status) */
|
||||
export const ROLE_STATUS_ACTIONS = ['enabled', 'disabled'] as const;
|
||||
|
||||
export const roleStatusActionSchema = z.enum(ROLE_STATUS_ACTIONS, {
|
||||
errorMap: () => ({ message: '状态变更只能是 enabled 或 disabled' }),
|
||||
});
|
||||
|
||||
export type RoleStatusAction = z.infer<typeof roleStatusActionSchema>;
|
||||
|
||||
/** 角色列表筛选状态:默认 all(停用角色在列表中可查) */
|
||||
export const ROLE_LIST_STATUSES = ['all', 'active', 'disabled'] as const;
|
||||
|
||||
export const roleListStatusSchema = z.enum(ROLE_LIST_STATUSES, {
|
||||
errorMap: () => ({ message: '筛选状态只能是 all / active / disabled' }),
|
||||
});
|
||||
|
||||
export type RoleListStatus = z.infer<typeof roleListStatusSchema>;
|
||||
|
||||
/** 角色列表排序字段 */
|
||||
export const ROLE_LIST_SORTS = ['created_at', 'name'] as const;
|
||||
|
||||
export const roleListSortSchema = z.enum(ROLE_LIST_SORTS, {
|
||||
errorMap: () => ({ message: '排序字段只能是 created_at 或 name' }),
|
||||
});
|
||||
|
||||
export type RoleListSort = z.infer<typeof roleListSortSchema>;
|
||||
|
||||
export const listOrderSchema = z.enum(['asc', 'desc'], {
|
||||
errorMap: () => ({ message: '排序方向只能是 asc 或 desc' }),
|
||||
});
|
||||
|
||||
export type ListOrder = z.infer<typeof listOrderSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据范围(角色声明 / 输出)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 角色数据范围入参:六类之一 + 可选授权对象(单选模型)。
|
||||
* - 一个角色只能有一种范围类型;
|
||||
* - 只有 `customer_groups` 允许 `targetIds`(多个客户组),其他类型必须为空 / 缺省;
|
||||
* - A3 前暂无客户组数据,`targetIds` 允许为空数组。
|
||||
*/
|
||||
export const roleDataScopeInputSchema = z
|
||||
.object({
|
||||
type: dataScopeTypeSchema,
|
||||
targetIds: z.array(ulidSchema).max(100, '单个范围最多 100 个授权对象').optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(scope) => scope.type === 'customer_groups' || (scope.targetIds ?? []).length === 0,
|
||||
'只有「指定客户组」范围允许填写 targetIds',
|
||||
);
|
||||
|
||||
export type RoleDataScopeInput = z.infer<typeof roleDataScopeInputSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 角色输出
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 角色绑定用户摘要(角色详情「绑定用户」区) */
|
||||
export const roleBoundUserSchema = z.object({
|
||||
id: ulidSchema,
|
||||
name: z.string().min(1, '用户姓名不能为空'),
|
||||
account: z.string().min(1, '账号不能为空'),
|
||||
phoneMasked: z.string().min(1, '手机号掩码不能为空'),
|
||||
departmentName: z.string().min(1, '部门名称不能为空'),
|
||||
status: userStatusSchema,
|
||||
});
|
||||
|
||||
export type RoleBoundUser = z.infer<typeof roleBoundUserSchema>;
|
||||
|
||||
/** 角色列表项:统计字段 + 数据范围(不含权限点明细,避免列表过大) */
|
||||
export const roleListItemSchema = z.object({
|
||||
id: ulidSchema,
|
||||
code: z.string().min(1, '角色编码不能为空'),
|
||||
name: z.string().min(1, '角色名称不能为空'),
|
||||
description: z.string(),
|
||||
isSystem: z.boolean(),
|
||||
status: roleStatusSchema,
|
||||
/** 普通权限点数量(不含特殊权限) */
|
||||
permissionCount: z.number().int('权限点数量必须是整数').nonnegative('权限点数量不能为负数'),
|
||||
/** 特殊权限点数量 */
|
||||
specialPermissionCount: z
|
||||
.number()
|
||||
.int('特殊权限数量必须是整数')
|
||||
.nonnegative('特殊权限数量不能为负数'),
|
||||
/** 绑定用户数(含停用用户,用于删除前校验与影响面提示) */
|
||||
userCount: z.number().int('用户数必须是整数').nonnegative('用户数不能为负数'),
|
||||
/** 角色声明的数据范围(单选;指定客户组时带 targetIds,不含解析后的 customerIds) */
|
||||
dataScope: dataScopeSchema,
|
||||
createdAt: isoDateTimeSchema,
|
||||
updatedAt: isoDateTimeSchema,
|
||||
});
|
||||
|
||||
export type RoleListItem = z.infer<typeof roleListItemSchema>;
|
||||
|
||||
/** 角色详情 = 列表项 + 权限点明细 + 绑定用户 */
|
||||
export const roleDetailSchema = roleListItemSchema.extend({
|
||||
permissions: z.array(permissionCodeSchema),
|
||||
specialPermissions: z.array(permissionCodeSchema),
|
||||
boundUsers: z.array(roleBoundUserSchema),
|
||||
});
|
||||
|
||||
export type RoleDetail = z.infer<typeof roleDetailSchema>;
|
||||
|
||||
export const roleListResponseSchema = z.object({
|
||||
items: z.array(roleListItemSchema),
|
||||
next_cursor: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type RoleListResponse = z.infer<typeof roleListResponseSchema>;
|
||||
|
||||
/** 角色保存结果(POST / PATCH 共用):详情 + 影响用户数 + 软提示 */
|
||||
export const roleSaveResultSchema = z.object({
|
||||
role: roleDetailSchema,
|
||||
/** 本次变更影响的绑定用户数(PATCH 返回;POST 新建为 0) */
|
||||
affectedUserCount: z.number().int('影响用户数必须是整数').nonnegative('影响用户数不能为负数'),
|
||||
/** 软提示(角色数量 / 超管人数超过建议值等),不阻断保存 */
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type RoleSaveResult = z.infer<typeof roleSaveResultSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 角色输入
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rolePermissionListSchema = z
|
||||
.array(permissionCodeSchema)
|
||||
.max(100, '权限点数量超出上限')
|
||||
.optional();
|
||||
|
||||
function assertPermissionBuckets(
|
||||
input: { permissions?: string[]; specialPermissions?: string[] },
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
for (const code of input.permissions ?? []) {
|
||||
if (isSpecialPermission(code)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['permissions'],
|
||||
message: `特殊权限 ${code} 必须放入 specialPermissions 单独授予`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const code of input.specialPermissions ?? []) {
|
||||
if (!isSpecialPermission(code)) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['specialPermissions'],
|
||||
message: `普通权限 ${code} 不能放入 specialPermissions`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建角色:权限点与特殊权限默认全空,`code` 由后端生成。
|
||||
* `dataScope` 必填(六类单选);未显式选择时前端应默认 `created_by_me` 最小权限。
|
||||
*/
|
||||
export const createRoleInputSchema = z
|
||||
.object({
|
||||
name: roleNameSchema,
|
||||
description: roleDescriptionSchema.optional(),
|
||||
/** 普通权限点(不得包含特殊权限) */
|
||||
permissions: rolePermissionListSchema,
|
||||
/** 特殊权限点(必须全部是特殊权限,单独授予) */
|
||||
specialPermissions: rolePermissionListSchema,
|
||||
/** 数据范围(必填,单选) */
|
||||
dataScope: roleDataScopeInputSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(assertPermissionBuckets);
|
||||
|
||||
export type CreateRoleInput = z.infer<typeof createRoleInputSchema>;
|
||||
|
||||
/** 更新角色:字段级可选;status 为 enabled / disabled;系统角色仅显示名可改 */
|
||||
export const updateRoleInputSchema = z
|
||||
.object({
|
||||
name: roleNameSchema.optional(),
|
||||
description: roleDescriptionSchema.optional(),
|
||||
permissions: rolePermissionListSchema,
|
||||
specialPermissions: rolePermissionListSchema,
|
||||
/** 数据范围(省略则保持原值) */
|
||||
dataScope: roleDataScopeInputSchema.optional(),
|
||||
status: roleStatusActionSchema.optional(),
|
||||
/** 停用原因(status=disabled 时记录审计) */
|
||||
reason: z.string().trim().max(200, '停用原因最多 200 个字符').optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(assertPermissionBuckets);
|
||||
|
||||
export type UpdateRoleInput = z.infer<typeof updateRoleInputSchema>;
|
||||
|
||||
/** 停用角色入参(PATCH status 的停用分支;系统角色拒绝) */
|
||||
export const disableRoleInputSchema = z
|
||||
.object({
|
||||
status: z.literal('disabled'),
|
||||
reason: z.string().trim().max(200, '停用原因最多 200 个字符').optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DisableRoleInput = z.infer<typeof disableRoleInputSchema>;
|
||||
|
||||
/**
|
||||
* 角色列表 query(snake_case,默认按创建时间倒序)。
|
||||
* 默认 `status=active`:兼容 A1 用户管理页的角色多选(只返回启用角色);
|
||||
* 角色管理页需要查看全部 / 停用时显式传 `status=all` / `status=disabled`。
|
||||
*/
|
||||
export const roleListQuerySchema = cursorPaginationQuerySchema.extend({
|
||||
keyword: z.string().trim().max(64, '关键词最多 64 个字符').optional(),
|
||||
status: roleListStatusSchema.default('active'),
|
||||
sort: roleListSortSchema.default('created_at'),
|
||||
order: listOrderSchema.default('desc'),
|
||||
});
|
||||
|
||||
export type RoleListQuery = z.infer<typeof roleListQuerySchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限点目录(GET /permissions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const permissionNodeSchema = z.object({
|
||||
code: z.string().min(1, '权限点编码不能为空'),
|
||||
name: z.string().min(1, '权限点名称不能为空'),
|
||||
description: z.string(),
|
||||
isSpecial: z.boolean(),
|
||||
});
|
||||
|
||||
export type PermissionNode = z.infer<typeof permissionNodeSchema>;
|
||||
|
||||
export const permissionModuleNodeSchema = z.object({
|
||||
module: z.string().min(1, '模块编码不能为空'),
|
||||
moduleLabel: z.string().min(1, '模块名称不能为空'),
|
||||
sortOrder: z.number().int(),
|
||||
permissions: z.array(permissionNodeSchema),
|
||||
});
|
||||
|
||||
export type PermissionModuleNode = z.infer<typeof permissionModuleNodeSchema>;
|
||||
|
||||
export const sodPairSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string(),
|
||||
permissions: z.tuple([z.string().min(1), z.string().min(1)]),
|
||||
});
|
||||
|
||||
export type SodPairItem = z.infer<typeof sodPairSchema>;
|
||||
|
||||
/** GET /permissions:10 模块树 + 特殊权限汇总 + SoD 互斥对(前端勾选树与冲突高亮同源) */
|
||||
export const permissionTreeSchema = z.object({
|
||||
modules: z.array(permissionModuleNodeSchema),
|
||||
specialPermissions: z.array(permissionNodeSchema),
|
||||
sodPairs: z.array(sodPairSchema),
|
||||
totalPermissions: z.number().int().positive('权限点总数必须为正整数'),
|
||||
});
|
||||
|
||||
export type PermissionTree = z.infer<typeof permissionTreeSchema>;
|
||||
Reference in new issue
Block a user