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:
Stev_Wang committed 2026-11-03 12:26:51 +08:00
1 parent f97f97b0cd
commit 61894df401
80 files changed
+14380 -237

No files matched your search

+188 -5
View File
@@ -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);
});
});