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
+194
-62
@@ -1,15 +1,25 @@
|
||||
// 灵犀 · 种子数据(A1)
|
||||
// 灵犀 · 种子数据(A2)
|
||||
// 原则:
|
||||
// 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖;
|
||||
// 2) 角色-权限点、用户-角色为种子可重建映射:先清后建(限定在种子角色 / 种子用户范围内);
|
||||
// 3) 超管、部门、示例用户口径见 prisma/SEED.md。
|
||||
// 2) 权限点目录、角色-权限点、角色-数据范围、用户-角色为种子可重建映射:先清后建
|
||||
// (限定在种子角色 / 种子用户范围内;生产禁止直接跑 seed);
|
||||
// 3) 权限点编码唯一事实源是 packages/shared/src/permissions.ts,本文件不复制编码字符串;
|
||||
// 4) 超管、部门、示例角色 / 用户口径见 prisma/SEED.md。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { PrismaClient, type UserStatus } from '@prisma/client';
|
||||
import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
|
||||
// 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移)
|
||||
import {
|
||||
findSodConflicts,
|
||||
Permission,
|
||||
PERMISSION_CATALOG,
|
||||
splitPermissions,
|
||||
} from '../packages/shared/src/permissions.js';
|
||||
|
||||
for (const file of ['.env.local', '.env']) {
|
||||
const path = resolve(process.cwd(), file);
|
||||
if (existsSync(path)) {
|
||||
@@ -144,42 +154,35 @@ interface SeedRole {
|
||||
name: string;
|
||||
description: string;
|
||||
isSystem: boolean;
|
||||
status?: RoleStatus;
|
||||
/** 普通权限点(不得包含特殊权限;特殊权限必须放 specialPermissions 显式单独授予) */
|
||||
permissions: string[];
|
||||
/** 特殊权限点:默认关闭,仅在种子里显式授予(原型口径:超管 6 + 平台管理员 1 + AI 工程师 1) */
|
||||
specialPermissions?: string[];
|
||||
/** 数据范围声明(一个角色只允许一种类型;A3 前「指定客户组」targetIds 为空) */
|
||||
dataScope: SeedDataScope;
|
||||
}
|
||||
|
||||
interface SeedDataScope {
|
||||
type: string;
|
||||
targetIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 种子角色与权限映射:与 design/a2-ui-v1/brief.md §4 的共享角色数据对齐
|
||||
* (各角色普通权限数 12 / 9 / 4 / 2 / 4 / 3 / 5 / 4 / 5,特殊权限授予 8 项)。
|
||||
*/
|
||||
const ROLES: SeedRole[] = [
|
||||
{
|
||||
code: 'super_admin',
|
||||
name: '超级管理员',
|
||||
description: '内置角色,拥有全部权限点',
|
||||
description: '内置角色,拥有系统全部权限',
|
||||
isSystem: true,
|
||||
permissions: [
|
||||
'script:read',
|
||||
'script:write',
|
||||
'script:review',
|
||||
'material:read',
|
||||
'material:write',
|
||||
'material:render',
|
||||
'material:review',
|
||||
'asset:read',
|
||||
'asset:write',
|
||||
'delivery:read',
|
||||
'delivery:export',
|
||||
'customer:read',
|
||||
'customer:manage',
|
||||
'customer_field:approve',
|
||||
'user:view',
|
||||
'user:manage',
|
||||
'role:manage',
|
||||
'industry:manage',
|
||||
'industry:publish',
|
||||
'dict:manage',
|
||||
'form:design',
|
||||
'ai:configure',
|
||||
'system:configure',
|
||||
'audit:read',
|
||||
'audit:export',
|
||||
] as string[],
|
||||
permissions: PERMISSION_CATALOG.filter((item) => !item.isSpecial).map((item) => item.code),
|
||||
specialPermissions: PERMISSION_CATALOG.filter((item) => item.isSpecial).map(
|
||||
(item) => item.code,
|
||||
),
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
{
|
||||
code: 'platform_admin',
|
||||
@@ -187,16 +190,21 @@ const ROLES: SeedRole[] = [
|
||||
description: '组织、角色与平台配置管理',
|
||||
isSystem: false,
|
||||
permissions: [
|
||||
'user:view',
|
||||
'user:manage',
|
||||
'role:manage',
|
||||
'system:configure',
|
||||
'audit:read',
|
||||
'industry:manage',
|
||||
'dict:manage',
|
||||
'form:design',
|
||||
'customer:read',
|
||||
Permission.USER_VIEW,
|
||||
Permission.USER_MANAGE,
|
||||
Permission.ROLE_VIEW,
|
||||
Permission.ROLE_MANAGE,
|
||||
Permission.SYSTEM_CONFIGURE,
|
||||
Permission.INDUSTRY_MANAGE,
|
||||
Permission.DICT_MANAGE,
|
||||
Permission.FORM_DESIGN,
|
||||
Permission.CUSTOMER_READ,
|
||||
Permission.CUSTOMER_GROUP_MANAGE,
|
||||
Permission.DELIVERY_READ,
|
||||
Permission.ASSET_READ,
|
||||
],
|
||||
specialPermissions: [Permission.AUDIT_READ],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
{
|
||||
code: 'material_operator',
|
||||
@@ -204,50 +212,106 @@ const ROLES: SeedRole[] = [
|
||||
description: '素材生产、快筛、导出与归档',
|
||||
isSystem: false,
|
||||
permissions: [
|
||||
'script:read',
|
||||
'script:write',
|
||||
'material:read',
|
||||
'material:write',
|
||||
'material:render',
|
||||
'asset:read',
|
||||
'asset:write',
|
||||
'delivery:read',
|
||||
Permission.SCRIPT_READ,
|
||||
Permission.SCRIPT_WRITE,
|
||||
Permission.MATERIAL_READ,
|
||||
Permission.MATERIAL_WRITE,
|
||||
Permission.MATERIAL_RENDER,
|
||||
Permission.ASSET_READ,
|
||||
Permission.ASSET_WRITE,
|
||||
Permission.DELIVERY_READ,
|
||||
Permission.CUSTOMER_READ,
|
||||
],
|
||||
dataScope: { type: 'owned_customers' },
|
||||
},
|
||||
{
|
||||
code: 'designer',
|
||||
name: '设计师',
|
||||
description: '素材设计与资产维护',
|
||||
isSystem: false,
|
||||
permissions: ['material:read', 'material:write', 'asset:read', 'asset:write'],
|
||||
permissions: [
|
||||
Permission.MATERIAL_READ,
|
||||
Permission.MATERIAL_WRITE,
|
||||
Permission.ASSET_READ,
|
||||
Permission.ASSET_WRITE,
|
||||
],
|
||||
dataScope: { type: 'owned_customers' },
|
||||
},
|
||||
{
|
||||
code: 'copywriter',
|
||||
name: '文案策划',
|
||||
description: '脚本撰写与要素卡维护',
|
||||
isSystem: false,
|
||||
permissions: ['script:read', 'script:write'],
|
||||
permissions: [Permission.SCRIPT_READ, Permission.SCRIPT_WRITE],
|
||||
dataScope: { type: 'department' },
|
||||
},
|
||||
{
|
||||
code: 'reviewer',
|
||||
name: '审核员',
|
||||
description: '脚本与素材审核',
|
||||
isSystem: false,
|
||||
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
|
||||
permissions: [
|
||||
Permission.SCRIPT_READ,
|
||||
Permission.SCRIPT_REVIEW,
|
||||
Permission.MATERIAL_READ,
|
||||
Permission.MATERIAL_REVIEW,
|
||||
],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
},
|
||||
{
|
||||
code: 'ai_engineer',
|
||||
name: 'AI 工程师',
|
||||
description: '模型接入与生成链路维护',
|
||||
isSystem: false,
|
||||
permissions: ['material:read', 'material:render', 'system:configure'],
|
||||
permissions: [
|
||||
Permission.MATERIAL_READ,
|
||||
Permission.MATERIAL_RENDER,
|
||||
Permission.SYSTEM_CONFIGURE,
|
||||
],
|
||||
specialPermissions: [Permission.AI_CONFIGURE],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
{
|
||||
code: 'account_owner',
|
||||
name: '客户负责人',
|
||||
description: '客户资料查看、脚本审核与交付验收',
|
||||
description: '客户资料、脚本审核与交付验收',
|
||||
isSystem: false,
|
||||
permissions: ['customer:read', 'customer:manage', 'delivery:read', 'script:review'],
|
||||
permissions: [
|
||||
Permission.CUSTOMER_READ,
|
||||
Permission.CUSTOMER_MANAGE,
|
||||
Permission.CUSTOMER_TRANSFER,
|
||||
Permission.DELIVERY_READ,
|
||||
Permission.SCRIPT_REVIEW,
|
||||
],
|
||||
dataScope: { type: 'customer_groups', targetIds: [] },
|
||||
},
|
||||
{
|
||||
code: 'industry_config',
|
||||
name: '行业配置',
|
||||
description: '行业包、表单与字典维护',
|
||||
isSystem: false,
|
||||
permissions: [
|
||||
Permission.INDUSTRY_MANAGE,
|
||||
Permission.FORM_DESIGN,
|
||||
Permission.DICT_MANAGE,
|
||||
Permission.CUSTOMER_READ,
|
||||
],
|
||||
dataScope: { type: 'all' },
|
||||
},
|
||||
{
|
||||
code: 'review_lead',
|
||||
name: '审核主管',
|
||||
description: '原审核组主管角色,已并入审核员',
|
||||
isSystem: false,
|
||||
status: 'DISABLED',
|
||||
permissions: [
|
||||
Permission.SCRIPT_READ,
|
||||
Permission.SCRIPT_REVIEW,
|
||||
Permission.MATERIAL_READ,
|
||||
Permission.MATERIAL_REVIEW,
|
||||
Permission.CUSTOMER_READ,
|
||||
],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -401,6 +465,75 @@ async function seedDepartments(): Promise<void> {
|
||||
console.log(`[seed] departments 就绪:${String(DEPARTMENTS.length)} 个(幂等)`);
|
||||
}
|
||||
|
||||
async function seedPermissions(): Promise<void> {
|
||||
for (const item of PERMISSION_CATALOG) {
|
||||
await prisma.permission.upsert({
|
||||
where: { code: item.code },
|
||||
update: {
|
||||
module: item.module,
|
||||
moduleLabel: item.moduleLabel,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: item.isSpecial,
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
create: {
|
||||
code: item.code,
|
||||
module: item.module,
|
||||
moduleLabel: item.moduleLabel,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSpecial: item.isSpecial,
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`[seed] permissions 就绪:${String(PERMISSION_CATALOG.length)} 个(幂等,只增不删)`);
|
||||
}
|
||||
|
||||
async function seedRoleAccess(roleId: string, item: SeedRole): Promise<void> {
|
||||
const normal = splitPermissions(item.permissions);
|
||||
if (normal.specialPermissions.length > 0) {
|
||||
throw new Error(
|
||||
`种子角色 ${item.code} 的 permissions 混入特殊权限(必须放 specialPermissions):${normal.specialPermissions.join(',')}`,
|
||||
);
|
||||
}
|
||||
const special = splitPermissions(item.specialPermissions ?? []);
|
||||
if (special.permissions.length > 0) {
|
||||
throw new Error(
|
||||
`种子角色 ${item.code} 的 specialPermissions 混入普通权限:${special.permissions.join(',')}`,
|
||||
);
|
||||
}
|
||||
const conflicts = item.isSystem
|
||||
? []
|
||||
: findSodConflicts([...normal.permissions, ...special.specialPermissions]);
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(
|
||||
`种子角色 ${item.code} 违反职责互斥:${conflicts.map((c) => c.label).join(';')}`,
|
||||
);
|
||||
}
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId } });
|
||||
await prisma.rolePermission.createMany({
|
||||
data: [...normal.permissions, ...special.specialPermissions].map((permissionCode) => ({
|
||||
roleId,
|
||||
permissionCode,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await prisma.dataScope.deleteMany({ where: { roleId } });
|
||||
// 无授权对象(含 A3 前空客户组)也保留一行 target_id = NULL,确保范围类型不丢失(不降级为最小权限)
|
||||
const targetIds = item.dataScope.targetIds?.length ? item.dataScope.targetIds : [null];
|
||||
await prisma.dataScope.createMany({
|
||||
data: targetIds.map((targetId) => ({
|
||||
id: ulid(),
|
||||
roleId,
|
||||
scopeType: item.dataScope.type,
|
||||
targetId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedRoles(): Promise<void> {
|
||||
for (const item of ROLES) {
|
||||
const role = await prisma.role.upsert({
|
||||
@@ -412,16 +545,14 @@ async function seedRoles(): Promise<void> {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
isSystem: item.isSystem,
|
||||
status: 'ACTIVE',
|
||||
status: item.status ?? 'ACTIVE',
|
||||
},
|
||||
});
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId: role.id } });
|
||||
await prisma.rolePermission.createMany({
|
||||
data: item.permissions.map((permissionCode) => ({ roleId: role.id, permissionCode })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await seedRoleAccess(role.id, item);
|
||||
}
|
||||
console.log(`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点映射(幂等)`);
|
||||
console.log(
|
||||
`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点 / 数据范围映射(幂等,特殊权限按原型显式授予)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function seedUsers(): Promise<void> {
|
||||
@@ -478,6 +609,7 @@ async function seedDepartmentLeaders(): Promise<void> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await seedConfigs();
|
||||
await seedPermissions();
|
||||
await seedDepartments();
|
||||
await seedRoles();
|
||||
await seedUsers();
|
||||
|
||||
Reference in new issue
Block a user