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
@@ -6,5 +6,6 @@ export * from './permissions.js';
|
||||
export * from './schemas/api.js';
|
||||
export * from './schemas/common.js';
|
||||
export * from './schemas/identity.js';
|
||||
export * from './schemas/roles.js';
|
||||
export * from './time.js';
|
||||
export * from './ulid.js';
|
||||
@@ -1,14 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findSodConflicts,
|
||||
isPermissionCode,
|
||||
isSpecialPermission,
|
||||
parsePermission,
|
||||
Permission,
|
||||
PERMISSION_CATALOG,
|
||||
PERMISSION_CODES,
|
||||
PERMISSION_MODULES,
|
||||
permissionMetaOf,
|
||||
permissionsByModule,
|
||||
ROLE_SOFT_LIMIT,
|
||||
SOD_PAIRS,
|
||||
SPECIAL_PERMISSIONS,
|
||||
splitPermissions,
|
||||
SUPER_ADMIN_SOFT_LIMIT,
|
||||
} from './permissions.js';
|
||||
|
||||
describe('权限点常量', () => {
|
||||
describe('权限点目录', () => {
|
||||
it('H1 口径:共 30 个权限点,编码全局唯一', () => {
|
||||
expect(PERMISSION_CODES).toHaveLength(30);
|
||||
expect(PERMISSION_CATALOG).toHaveLength(30);
|
||||
expect(new Set(PERMISSION_CODES).size).toBe(30);
|
||||
});
|
||||
|
||||
it('全部符合 模块:动作 命名且模块为小写', () => {
|
||||
for (const code of PERMISSION_CODES) {
|
||||
expect(code).toMatch(/^[a-z][a-z_]*:[a-z][a-z_]*$/);
|
||||
@@ -16,15 +32,175 @@ describe('权限点常量', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('特殊权限必须是已登记的权限点', () => {
|
||||
it('保留既有 read / write 编码,并补齐 H1 新增的 5 个权限点', () => {
|
||||
for (const code of [
|
||||
'script:read',
|
||||
'script:write',
|
||||
'material:read',
|
||||
'material:write',
|
||||
'asset:read',
|
||||
'asset:write',
|
||||
'delivery:read',
|
||||
'customer:read',
|
||||
]) {
|
||||
expect(PERMISSION_CODES).toContain(code);
|
||||
}
|
||||
expect(Permission.ROLE_VIEW).toBe('role:view');
|
||||
expect(Permission.CUSTOMER_FIELD_APPLY).toBe('customer_field:apply');
|
||||
expect(Permission.CUSTOMER_TRANSFER).toBe('customer:transfer');
|
||||
expect(Permission.CUSTOMER_GROUP_MANAGE).toBe('customer_group:manage');
|
||||
expect(Permission.ASSET_DELETE).toBe('asset:delete');
|
||||
});
|
||||
|
||||
it('权限树为 10 个模块,顺序与名称对齐 design/a2-ui-v1/brief.md', () => {
|
||||
const groups = permissionsByModule();
|
||||
expect(groups).toHaveLength(10);
|
||||
expect(PERMISSION_MODULES).toHaveLength(10);
|
||||
expect(groups.map((group) => group.module)).toEqual([
|
||||
'script',
|
||||
'material',
|
||||
'asset',
|
||||
'delivery',
|
||||
'customer',
|
||||
'customer_group',
|
||||
'identity',
|
||||
'industry',
|
||||
'platform',
|
||||
'audit',
|
||||
]);
|
||||
expect(groups.map((group) => group.moduleLabel)).toEqual([
|
||||
'脚本',
|
||||
'素材',
|
||||
'资产',
|
||||
'导出交付',
|
||||
'客户',
|
||||
'客户组',
|
||||
'组织与角色',
|
||||
'行业包',
|
||||
'平台配置',
|
||||
'审计',
|
||||
]);
|
||||
expect(groups.map((group) => group.sortOrder)).toEqual([
|
||||
10, 20, 30, 40, 50, 60, 70, 80, 90, 100,
|
||||
]);
|
||||
expect(groups.flatMap((group) => group.permissions)).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('权限点显示名与说明与 UI 原型一致(抽样关键项)', () => {
|
||||
const nameOf = (code: string): { name: string; description: string } => {
|
||||
const meta = permissionMetaOf(code);
|
||||
if (meta === undefined) {
|
||||
throw new Error(`权限点未登记:${code}`);
|
||||
}
|
||||
return { name: meta.name, description: meta.description };
|
||||
};
|
||||
expect(nameOf('script:read')).toEqual({ name: '查看脚本', description: '脚本库、模板与版本' });
|
||||
expect(nameOf('delivery:export')).toEqual({
|
||||
name: '发起导出',
|
||||
description: '数据离开平台的唯一出口(特殊)',
|
||||
});
|
||||
expect(nameOf('user:manage')).toEqual({ name: '管理用户', description: '新建、停用、转交' });
|
||||
expect(nameOf('customer_group:manage')).toEqual({
|
||||
name: '管理客户组',
|
||||
description: '客户组增改与成员',
|
||||
});
|
||||
expect(nameOf('audit:export')).toEqual({
|
||||
name: '导出审计',
|
||||
description: '审计导出并留痕(特殊)',
|
||||
});
|
||||
});
|
||||
|
||||
it('目录元数据完整(编码 / 模块 / 名称 / 说明 / 特殊标记 / 排序)', () => {
|
||||
for (const item of PERMISSION_CATALOG) {
|
||||
expect(permissionMetaOf(item.code)).toEqual(item);
|
||||
expect(item.name.length).toBeGreaterThan(0);
|
||||
expect(item.description.length).toBeGreaterThan(0);
|
||||
expect(item.moduleLabel.length).toBeGreaterThan(0);
|
||||
expect(item.isSpecial).toBe(isSpecialPermission(item.code));
|
||||
}
|
||||
const sortOrders = PERMISSION_CATALOG.map((item) => item.sortOrder);
|
||||
expect(sortOrders).toEqual([...sortOrders].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('特殊权限为 6 个编码(5 类),均已在目录登记', () => {
|
||||
expect(SPECIAL_PERMISSIONS).toHaveLength(6);
|
||||
expect([...SPECIAL_PERMISSIONS].sort()).toEqual(
|
||||
[
|
||||
'delivery:export',
|
||||
'customer_field:approve',
|
||||
'industry:publish',
|
||||
'ai:configure',
|
||||
'audit:read',
|
||||
'audit:export',
|
||||
].sort(),
|
||||
);
|
||||
for (const code of SPECIAL_PERMISSIONS) {
|
||||
expect(PERMISSION_CODES).toContain(code);
|
||||
expect(isSpecialPermission(code)).toBe(true);
|
||||
}
|
||||
expect(isSpecialPermission('script:write')).toBe(false);
|
||||
});
|
||||
|
||||
it('软上限取自 PRD 建议值', () => {
|
||||
expect(ROLE_SOFT_LIMIT).toBe(20);
|
||||
expect(SUPER_ADMIN_SOFT_LIMIT).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SoD 三对', () => {
|
||||
it('互斥对为三对,且两侧都是已登记权限点', () => {
|
||||
expect(SOD_PAIRS).toHaveLength(3);
|
||||
expect(SOD_PAIRS.map((pair) => pair.id)).toEqual([
|
||||
'script-write-review',
|
||||
'industry-manage-publish',
|
||||
'customer-field-apply-approve',
|
||||
]);
|
||||
for (const pair of SOD_PAIRS) {
|
||||
for (const code of pair.permissions) {
|
||||
expect(PERMISSION_CODES).toContain(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('isPermissionCode 只认可登记值', () => {
|
||||
expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true);
|
||||
expect(isPermissionCode('script:delete_all')).toBe(false);
|
||||
it('同时拥有互斥两侧即命中;只拥有单侧不命中', () => {
|
||||
expect(findSodConflicts(['script:write', 'script:review'])).toEqual([
|
||||
{
|
||||
pairId: 'script-write-review',
|
||||
label: '脚本生成 × 脚本审核',
|
||||
permissions: ['script:write', 'script:review'],
|
||||
},
|
||||
]);
|
||||
expect(findSodConflicts(['industry:manage', 'industry:publish']).map((c) => c.pairId)).toEqual([
|
||||
'industry-manage-publish',
|
||||
]);
|
||||
expect(
|
||||
findSodConflicts(['customer_field:apply', 'customer_field:approve']).map((c) => c.pairId),
|
||||
).toEqual(['customer-field-apply-approve']);
|
||||
expect(findSodConflicts(['script:write', 'script:read'])).toEqual([]);
|
||||
expect(findSodConflicts([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitPermissions', () => {
|
||||
it('拆分普通权限与特殊权限并去重排序', () => {
|
||||
expect(
|
||||
splitPermissions([
|
||||
'script:write',
|
||||
'delivery:export',
|
||||
'script:read',
|
||||
'delivery:export',
|
||||
'audit:read',
|
||||
]),
|
||||
).toEqual({
|
||||
permissions: ['script:read', 'script:write'],
|
||||
specialPermissions: ['audit:read', 'delivery:export'],
|
||||
});
|
||||
});
|
||||
|
||||
it('未登记编码抛错(调用方映射 40001)', () => {
|
||||
expect(() => splitPermissions(['script:delete_all'])).toThrow(
|
||||
'INVALID_PERMISSION_CODE:script:delete_all',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,3 +216,10 @@ describe('parsePermission', () => {
|
||||
expect(parsePermission('a:b:c')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPermissionCode', () => {
|
||||
it('只认可登记值', () => {
|
||||
expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true);
|
||||
expect(isPermissionCode('script:delete_all')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,41 @@
|
||||
/**
|
||||
* 权限点常量骨架(A0-SH-01)
|
||||
* 命名规范:<模块>:<动作>,模块取后端八模块语义(identity / client / catalog / industry /
|
||||
* script / media / delivery / platform),全小写;动作使用动词或名词短语。
|
||||
* A1/A2 接入真实角色-权限表后,这里只增不改;删除/改名需评审(AGENTS §10)。
|
||||
* 权限点目录(A0-SH-01 骨架 → A2 定稿,口径见 H1 权限门)
|
||||
*
|
||||
* 命名规范:<模块>:<动作>,全小写;权限树一级模块(script / material / asset / delivery /
|
||||
* customer / customer_group / identity / industry / platform / audit)与 UI 原型一致,
|
||||
* 动作使用动词或名词短语。
|
||||
*
|
||||
* 约束(AGENTS §10 / 开发计划 §14 附录 B):
|
||||
* - 权限点总共 30 个:保留 A0/A1 已有的 read / write 编码,只补齐 H1 确认的 5 个新编码;
|
||||
* - 新增 / 删除 / 改名权限点必须评审,`code` 一经发布不得变更(角色数据按 code 关联);
|
||||
* - 特殊权限 6 个编码(5 类):默认关闭、单独授予、不随角色复制带入;
|
||||
* - 本文件是唯一事实源:后端启动时同步 `permissions` 表,前端权限树由 `/permissions` 生成;
|
||||
* 本文件不依赖任何其他模块,保证 `@lingxi/shared` 零业务依赖。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 模块(权限树一级分组,共 10 个;与 design/a2-ui-v1/brief.md 对齐,顺序固定)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PERMISSION_MODULES = [
|
||||
{ module: 'script', moduleLabel: '脚本', sortOrder: 10 },
|
||||
{ module: 'material', moduleLabel: '素材', sortOrder: 20 },
|
||||
{ module: 'asset', moduleLabel: '资产', sortOrder: 30 },
|
||||
{ module: 'delivery', moduleLabel: '导出交付', sortOrder: 40 },
|
||||
{ module: 'customer', moduleLabel: '客户', sortOrder: 50 },
|
||||
{ module: 'customer_group', moduleLabel: '客户组', sortOrder: 60 },
|
||||
{ module: 'identity', moduleLabel: '组织与角色', sortOrder: 70 },
|
||||
{ module: 'industry', moduleLabel: '行业包', sortOrder: 80 },
|
||||
{ module: 'platform', moduleLabel: '平台配置', sortOrder: 90 },
|
||||
{ module: 'audit', moduleLabel: '审计', sortOrder: 100 },
|
||||
] as const;
|
||||
|
||||
export type PermissionModule = (typeof PERMISSION_MODULES)[number]['module'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限点编码(30 个)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Permission = {
|
||||
// 脚本
|
||||
SCRIPT_READ: 'script:read',
|
||||
@@ -17,20 +49,27 @@ export const Permission = {
|
||||
// 资产
|
||||
ASSET_READ: 'asset:read',
|
||||
ASSET_WRITE: 'asset:write',
|
||||
// 导出交付
|
||||
ASSET_DELETE: 'asset:delete',
|
||||
// 交付导出
|
||||
DELIVERY_READ: 'delivery:read',
|
||||
DELIVERY_EXPORT: 'delivery:export',
|
||||
// 客户
|
||||
CUSTOMER_READ: 'customer:read',
|
||||
CUSTOMER_MANAGE: 'customer:manage',
|
||||
CUSTOMER_TRANSFER: 'customer:transfer',
|
||||
CUSTOMER_FIELD_APPLY: 'customer_field:apply',
|
||||
CUSTOMER_FIELD_APPROVE: 'customer_field:approve',
|
||||
// 客户组
|
||||
CUSTOMER_GROUP_MANAGE: 'customer_group:manage',
|
||||
// 组织与角色
|
||||
USER_VIEW: 'user:view',
|
||||
USER_MANAGE: 'user:manage',
|
||||
ROLE_VIEW: 'role:view',
|
||||
ROLE_MANAGE: 'role:manage',
|
||||
// 平台配置
|
||||
// 行业包
|
||||
INDUSTRY_MANAGE: 'industry:manage',
|
||||
INDUSTRY_PUBLISH: 'industry:publish',
|
||||
// 平台配置
|
||||
DICT_MANAGE: 'dict:manage',
|
||||
FORM_DESIGN: 'form:design',
|
||||
AI_CONFIGURE: 'ai:configure',
|
||||
@@ -42,23 +81,342 @@ export const Permission = {
|
||||
|
||||
export type PermissionCode = (typeof Permission)[keyof typeof Permission];
|
||||
|
||||
export const PERMISSION_CODES: readonly PermissionCode[] = Object.values(Permission);
|
||||
// ---------------------------------------------------------------------------
|
||||
// 特殊权限(6 编码 / 5 类,默认关闭、单独授予、不随角色复制带入)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 需要额外单独校验的特殊权限(技术架构 §6.2「关键特殊权限」) */
|
||||
/** 需要 SpecialPermissionGuard 单独校验的特殊权限(架构 §6.2 / PRD §5.4.3 五) */
|
||||
export const SPECIAL_PERMISSIONS: readonly PermissionCode[] = [
|
||||
Permission.DELIVERY_EXPORT,
|
||||
Permission.AI_CONFIGURE,
|
||||
Permission.AUDIT_EXPORT,
|
||||
Permission.INDUSTRY_PUBLISH,
|
||||
Permission.CUSTOMER_FIELD_APPROVE,
|
||||
Permission.INDUSTRY_PUBLISH,
|
||||
Permission.AI_CONFIGURE,
|
||||
Permission.AUDIT_READ,
|
||||
Permission.AUDIT_EXPORT,
|
||||
];
|
||||
|
||||
const PERMISSION_CODE_SET: ReadonlySet<string> = new Set(PERMISSION_CODES);
|
||||
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
|
||||
|
||||
export function isSpecialPermission(code: string): boolean {
|
||||
return SPECIAL_PERMISSION_SET.has(code);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 职责互斥三对(SoD;超管豁免,命中返回 40304)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SodPair {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
permissions: readonly [PermissionCode, PermissionCode];
|
||||
}
|
||||
|
||||
export interface SodConflict {
|
||||
pairId: string;
|
||||
label: string;
|
||||
permissions: readonly [PermissionCode, PermissionCode];
|
||||
}
|
||||
|
||||
export const SOD_PAIRS: readonly SodPair[] = [
|
||||
{
|
||||
id: 'script-write-review',
|
||||
label: '脚本生成 × 脚本审核',
|
||||
description: '自己写自己审会让审核形同虚设,同一角色不得同时拥有「脚本生成」与「脚本审核」。',
|
||||
permissions: [Permission.SCRIPT_WRITE, Permission.SCRIPT_REVIEW],
|
||||
},
|
||||
{
|
||||
id: 'industry-manage-publish',
|
||||
label: '行业包编辑 × 行业包发布',
|
||||
description: '行业包改动影响所有客户,编辑与发布必须由两个角色分别承担。',
|
||||
permissions: [Permission.INDUSTRY_MANAGE, Permission.INDUSTRY_PUBLISH],
|
||||
},
|
||||
{
|
||||
id: 'customer-field-apply-approve',
|
||||
label: '客户字段申请 × 客户字段审批',
|
||||
description: '字段申请与审批不得由同一角色完成,避免绕过审批直接进入生成。',
|
||||
permissions: [Permission.CUSTOMER_FIELD_APPLY, Permission.CUSTOMER_FIELD_APPROVE],
|
||||
},
|
||||
];
|
||||
|
||||
/** 命中任意一对 SoD 即返回该对明细(role 保存前校验,超管豁免) */
|
||||
export function findSodConflicts(codes: readonly string[]): SodConflict[] {
|
||||
const codeSet = new Set(codes);
|
||||
return SOD_PAIRS.filter(
|
||||
(pair) => codeSet.has(pair.permissions[0]) && codeSet.has(pair.permissions[1]),
|
||||
).map((pair) => ({
|
||||
pairId: pair.id,
|
||||
label: pair.label,
|
||||
permissions: pair.permissions,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 软上限(建议值,仅提示不阻断;A4 迁 system_configs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 角色数量建议上限(PRD §5.4.3 数量建议:角色 ≤ 20) */
|
||||
export const ROLE_SOFT_LIMIT = 20;
|
||||
|
||||
/** 超级管理员人数建议上限(PRD §5.4.1:建议 ≤ 2 人) */
|
||||
export const SUPER_ADMIN_SOFT_LIMIT = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限点目录元数据
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PermissionMeta {
|
||||
code: PermissionCode;
|
||||
module: PermissionModule;
|
||||
moduleLabel: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSpecial: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface PermissionDefinition {
|
||||
code: PermissionCode;
|
||||
module: PermissionModule;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 目录定义(按模块 + 操作顺序),`PERMISSION_CATALOG` 由此生成 */
|
||||
const PERMISSION_DEFINITIONS: readonly PermissionDefinition[] = [
|
||||
// 脚本
|
||||
{
|
||||
code: Permission.SCRIPT_READ,
|
||||
module: 'script',
|
||||
name: '查看脚本',
|
||||
description: '脚本库、模板与版本',
|
||||
},
|
||||
{
|
||||
code: Permission.SCRIPT_WRITE,
|
||||
module: 'script',
|
||||
name: '编辑与生成脚本',
|
||||
description: '编辑脚本、发起生成',
|
||||
},
|
||||
{
|
||||
code: Permission.SCRIPT_REVIEW,
|
||||
module: 'script',
|
||||
name: '审核脚本',
|
||||
description: '脚本入库审核',
|
||||
},
|
||||
// 素材
|
||||
{
|
||||
code: Permission.MATERIAL_READ,
|
||||
module: 'material',
|
||||
name: '查看素材任务',
|
||||
description: '素材与任务详情',
|
||||
},
|
||||
{
|
||||
code: Permission.MATERIAL_WRITE,
|
||||
module: 'material',
|
||||
name: '编辑素材任务',
|
||||
description: '参数与素材信息',
|
||||
},
|
||||
{
|
||||
code: Permission.MATERIAL_RENDER,
|
||||
module: 'material',
|
||||
name: '发起生成与渲染',
|
||||
description: '图片 / 视频生成',
|
||||
},
|
||||
{
|
||||
code: Permission.MATERIAL_REVIEW,
|
||||
module: 'material',
|
||||
name: '审核素材',
|
||||
description: '素材入库审核',
|
||||
},
|
||||
// 资产
|
||||
{ code: Permission.ASSET_READ, module: 'asset', name: '查看资产', description: '资产库与版本' },
|
||||
{
|
||||
code: Permission.ASSET_WRITE,
|
||||
module: 'asset',
|
||||
name: '上传与编辑资产',
|
||||
description: '上传、编辑、启停',
|
||||
},
|
||||
{
|
||||
code: Permission.ASSET_DELETE,
|
||||
module: 'asset',
|
||||
name: '删除资产',
|
||||
description: '受引用保护,仅未被引用可删',
|
||||
},
|
||||
// 导出交付
|
||||
{
|
||||
code: Permission.DELIVERY_READ,
|
||||
module: 'delivery',
|
||||
name: '查看交付记录',
|
||||
description: '交付清单与导出记录',
|
||||
},
|
||||
{
|
||||
code: Permission.DELIVERY_EXPORT,
|
||||
module: 'delivery',
|
||||
name: '发起导出',
|
||||
description: '数据离开平台的唯一出口(特殊)',
|
||||
},
|
||||
// 客户
|
||||
{
|
||||
code: Permission.CUSTOMER_READ,
|
||||
module: 'customer',
|
||||
name: '查看客户',
|
||||
description: '客户资料与归属',
|
||||
},
|
||||
{
|
||||
code: Permission.CUSTOMER_MANAGE,
|
||||
module: 'customer',
|
||||
name: '管理客户资料',
|
||||
description: '新建、编辑、停用',
|
||||
},
|
||||
{
|
||||
code: Permission.CUSTOMER_TRANSFER,
|
||||
module: 'customer',
|
||||
name: '转交客户',
|
||||
description: '变更客户负责人',
|
||||
},
|
||||
{
|
||||
code: Permission.CUSTOMER_FIELD_APPLY,
|
||||
module: 'customer',
|
||||
name: '申请客户字段',
|
||||
description: '提交扩展字段申请',
|
||||
},
|
||||
{
|
||||
code: Permission.CUSTOMER_FIELD_APPROVE,
|
||||
module: 'customer',
|
||||
name: '审批客户字段',
|
||||
description: '审批扩展字段(特殊)',
|
||||
},
|
||||
// 客户组
|
||||
{
|
||||
code: Permission.CUSTOMER_GROUP_MANAGE,
|
||||
module: 'customer_group',
|
||||
name: '管理客户组',
|
||||
description: '客户组增改与成员',
|
||||
},
|
||||
// 组织与角色
|
||||
{ code: Permission.USER_VIEW, module: 'identity', name: '查看用户', description: '用户与部门' },
|
||||
{
|
||||
code: Permission.USER_MANAGE,
|
||||
module: 'identity',
|
||||
name: '管理用户',
|
||||
description: '新建、停用、转交',
|
||||
},
|
||||
{
|
||||
code: Permission.ROLE_VIEW,
|
||||
module: 'identity',
|
||||
name: '查看角色',
|
||||
description: '角色、权限点与 SoD',
|
||||
},
|
||||
{
|
||||
code: Permission.ROLE_MANAGE,
|
||||
module: 'identity',
|
||||
name: '管理角色与授权',
|
||||
description: '新建、编辑、复制、停用',
|
||||
},
|
||||
// 行业包
|
||||
{
|
||||
code: Permission.INDUSTRY_MANAGE,
|
||||
module: 'industry',
|
||||
name: '编辑行业包',
|
||||
description: '草稿、schema、规则集',
|
||||
},
|
||||
{
|
||||
code: Permission.INDUSTRY_PUBLISH,
|
||||
module: 'industry',
|
||||
name: '发布行业包',
|
||||
description: '发布后影响全部客户(特殊)',
|
||||
},
|
||||
// 平台配置
|
||||
{
|
||||
code: Permission.DICT_MANAGE,
|
||||
module: 'platform',
|
||||
name: '管理字典',
|
||||
description: '字典与系统配置项',
|
||||
},
|
||||
{ code: Permission.FORM_DESIGN, module: 'platform', name: '设计表单', description: '表单设计器' },
|
||||
{
|
||||
code: Permission.AI_CONFIGURE,
|
||||
module: 'platform',
|
||||
name: 'AI 配置',
|
||||
description: '模型、路由、额度(特殊)',
|
||||
},
|
||||
{
|
||||
code: Permission.SYSTEM_CONFIGURE,
|
||||
module: 'platform',
|
||||
name: '系统配置',
|
||||
description: '平台参数',
|
||||
},
|
||||
// 审计
|
||||
{
|
||||
code: Permission.AUDIT_READ,
|
||||
module: 'audit',
|
||||
name: '查看审计',
|
||||
description: '审计日志检索(特殊)',
|
||||
},
|
||||
{
|
||||
code: Permission.AUDIT_EXPORT,
|
||||
module: 'audit',
|
||||
name: '导出审计',
|
||||
description: '审计导出并留痕(特殊)',
|
||||
},
|
||||
];
|
||||
|
||||
export const PERMISSION_CATALOG: readonly PermissionMeta[] = PERMISSION_DEFINITIONS.map((item) => {
|
||||
const moduleMeta = PERMISSION_MODULES.find((module) => module.module === item.module);
|
||||
if (moduleMeta === undefined) {
|
||||
throw new Error(`权限点 ${item.code} 引用了未登记的模块:${item.module}`);
|
||||
}
|
||||
const indexInModule = PERMISSION_DEFINITIONS.filter(
|
||||
(candidate) => candidate.module === item.module,
|
||||
).findIndex((candidate) => candidate.code === item.code);
|
||||
return {
|
||||
code: item.code,
|
||||
module: item.module,
|
||||
moduleLabel: moduleMeta.moduleLabel,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: isSpecialPermission(item.code),
|
||||
sortOrder: moduleMeta.sortOrder + indexInModule,
|
||||
};
|
||||
});
|
||||
|
||||
export const PERMISSION_CODES: readonly PermissionCode[] = PERMISSION_CATALOG.map(
|
||||
(item) => item.code,
|
||||
);
|
||||
|
||||
const PERMISSION_CODE_SET: ReadonlySet<string> = new Set<string>(PERMISSION_CODES);
|
||||
|
||||
export function isPermissionCode(value: string): value is PermissionCode {
|
||||
return PERMISSION_CODE_SET.has(value);
|
||||
}
|
||||
|
||||
const PERMISSION_META_BY_CODE: ReadonlyMap<string, PermissionMeta> = new Map(
|
||||
PERMISSION_CATALOG.map((item) => [item.code, item]),
|
||||
);
|
||||
|
||||
export function permissionMetaOf(code: string): PermissionMeta | undefined {
|
||||
return PERMISSION_META_BY_CODE.get(code);
|
||||
}
|
||||
|
||||
/** 按模块分组(保持 PERMISSION_MODULES 顺序,空模块不返回) */
|
||||
export function permissionsByModule(): readonly {
|
||||
module: PermissionModule;
|
||||
moduleLabel: string;
|
||||
sortOrder: number;
|
||||
permissions: readonly PermissionMeta[];
|
||||
}[] {
|
||||
return PERMISSION_MODULES.map((moduleMeta) => ({
|
||||
module: moduleMeta.module,
|
||||
moduleLabel: moduleMeta.moduleLabel,
|
||||
sortOrder: moduleMeta.sortOrder,
|
||||
permissions: PERMISSION_CATALOG.filter((item) => item.module === moduleMeta.module),
|
||||
})).filter((group) => group.permissions.length > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 解析与校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedPermission {
|
||||
module: string;
|
||||
action: string;
|
||||
@@ -77,3 +435,32 @@ export function parsePermission(code: string): ParsedPermission | null {
|
||||
}
|
||||
return { module, action };
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并拆分角色保存入参的权限点:返回普通权限点与特殊权限点。
|
||||
* 未登记编码抛 `INVALID_PERMISSION_CODE:<code>`(由调用方映射为 40001)。
|
||||
*/
|
||||
export function splitPermissions(codes: readonly string[]): {
|
||||
permissions: PermissionCode[];
|
||||
specialPermissions: PermissionCode[];
|
||||
} {
|
||||
const unique = [...new Set(codes)];
|
||||
for (const code of unique) {
|
||||
if (!isPermissionCode(code)) {
|
||||
throw new Error(`INVALID_PERMISSION_CODE:${code}`);
|
||||
}
|
||||
}
|
||||
const permissions: PermissionCode[] = [];
|
||||
const specialPermissions: PermissionCode[] = [];
|
||||
for (const code of unique) {
|
||||
if (isSpecialPermission(code)) {
|
||||
specialPermissions.push(code as PermissionCode);
|
||||
} else {
|
||||
permissions.push(code as PermissionCode);
|
||||
}
|
||||
}
|
||||
return {
|
||||
permissions: permissions.sort(),
|
||||
specialPermissions: specialPermissions.sort(),
|
||||
};
|
||||
}
|
||||
@@ -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