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

+399 -12
View File
@@ -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(),
};
}