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

+24 -15
View File
@@ -1,16 +1,20 @@
# 种子数据状态(A1)
# 种子数据状态(A2)
A0 只 seed「已建表」的 `system_configs`;A1 建表完成后补齐组织、账号与角色种子。全部种子按业务唯一键 upsert,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。
A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角色种子;A2 补齐权限点目录、角色权限映射与六类数据范围种子。全部种子按业务唯一键 upsert,**幂等**:重复执行不重复插入,也不覆盖已存在的密码 / 人工调整值。
> 权限点编码唯一事实源是 `packages/shared/src/permissions.ts`(30 个 / 10 模块 / 6 个特殊权限 / 3 对 SoD),`seed.ts` 直接引用 shared 源码目录,不在本文件复制编码字符串。
## 已 seed(`prisma/seed.ts`)
| 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 |
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
| 角色 8 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 超级管理员(`is_system=true`,全部权限点)、平台管理员、素材运营、设计师、文案策划、审核员、AI 工程师、客户负责人;权限点取值来自 `@lingxi/shared` `PERMISSION_CODES`(本文件按字符串固化) |
| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
| 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 |
| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` |
| 权限点目录 30 个 | `permissions` | `code`(只增不删) | 10 个模块(script / material / asset / delivery / customer / customer_group / identity / industry / platform / audit);启动时由 `PermissionCatalogService` 幂等 upsert,seed 同步一次 |
| 角色 10 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 与 `design/a2-ui-v1/brief.md` §4 对齐:超级管理员(隐式全集,映射 24 普通 + 6 特殊)、平台管理员 12+1(`audit:read`)、素材运营 9、设计师 4、文案策划 2、审核员 4、AI 工程师 3+1(`ai:configure`)、客户负责人 5、行业配置 4、审核主管 5(停用);非超管角色合计 50 条映射,特殊授予共 8 项;新建角色仍默认全空、特殊权限单独授予 |
| 角色数据范围 | `data_scopes` | 角色 `code`;范围先清后建 | 一个角色只允许一种范围类型:超管 / 平台管理员 / AI 工程师 / 行业配置 = `all`;素材运营 / 设计师 = `owned_customers`;文案策划 = `department`;审核员 / 审核主管 = `department_and_children`;客户负责人 = `customer_groups`(A3 前无客户组,保留一行 `target_id = NULL` 以固定范围类型,不降级为最小权限) |
| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
## 登录账号(本地联调)
@@ -33,16 +37,21 @@ A0 只 seed「已建表」的 `system_configs`;A1 建表完成后补齐组织
## 使用
```bash
pnpm db:migrate # 应用 a1_identity 迁移(expand)
pnpm db:seed # 幂等写入上述种子(可重复执行)
pnpm db:migrate # 应用 a2_role_permissions_data_scopes 迁移(expand:permissions / data_scopes 两张新表)
pnpm db:seed # 幂等写入上述种子(可重复执行;空库与存量库均已验证)
# 本地重置:pnpm db:reset(会重新执行全部迁移与 seed)
```
## 与 A0 遗留 TODO 的关系
## 与 A1 / A2 遗留的关系
A0 记录的「超管 / 角色 / 示例用户待 A1」已在本次迁移与种子中补齐;字典(A4)、行业包(A3)仍按批次留待后续建表,不在 A1 种子内。原 `Q-A0-01`(种子验收与建表顺序冲突)随 A1 建表落地而解除。
- A1 记录的角色 / 用户种子已延续;A2 在其上补齐权限点目录、特殊权限口径(默认关闭、显式授予)与六类数据范围。
- 角色权限数按原型 `design/a2-ui-v1/brief.md` 对齐(12/9/4/2/4/3/5/4/5,特殊授予 8 项);用户仍为 A1 的 11 个示例账号,原型「绑定用户 28 名」属 UI 示例数据,未扩种用户。
- 字典(A4)、客户 / 客户组(A3)、行业包(A3)仍留待后续批次建表,不在本种子内。
- `data_scopes` 只存角色声明的范围与授权对象;解析后的可见客户集合由 `CustomerDirectory` 端口在请求期计算,A3 前为空集(种子不写客户 id)。
- 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。
## 已知限制
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户。
- 审计表属 A5,`@Audit` 当前写结构化日志;A5 落库后在此扩展日志 + 审计表双写。
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户;`customer_groups` 范围暂无可指向的客户组。
- 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。
- `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "permissions" (
"code" VARCHAR(64) NOT NULL,
"module" VARCHAR(32) NOT NULL,
"module_label" VARCHAR(32) NOT NULL,
"name" VARCHAR(64) NOT NULL,
"description" VARCHAR(255) NOT NULL DEFAULT '',
"is_special" BOOLEAN NOT NULL DEFAULT false,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "data_scopes" (
"id" CHAR(26) NOT NULL,
"role_id" CHAR(26) NOT NULL,
"scope_type" VARCHAR(32) NOT NULL,
"target_id" CHAR(26),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "data_scopes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "permissions_module_sort_order_idx" ON "permissions"("module", "sort_order");
-- CreateIndex
CREATE INDEX "data_scopes_role_id_idx" ON "data_scopes"("role_id");
-- CreateIndex
CREATE INDEX "data_scopes_scope_type_target_id_idx" ON "data_scopes"("scope_type", "target_id");
-- CreateIndex
CREATE UNIQUE INDEX "data_scopes_role_id_scope_type_target_id_key" ON "data_scopes"("role_id", "scope_type", "target_id");
-- AddForeignKey
ALTER TABLE "data_scopes" ADD CONSTRAINT "data_scopes_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+36
View File
@@ -122,6 +122,7 @@ model Role {
users UserRole[]
permissions RolePermission[]
dataScopes DataScope[]
@@map("roles")
}
@@ -153,6 +154,41 @@ model RolePermission {
@@map("role_permissions")
}
/// 权限点目录(A2-BE-02):唯一事实源在 @lingxi/shared PERMISSION_CATALOG;
/// 启动时幂等 upsert 同步,只增不删;前端权限树由目录生成(不手写迁移)。
model Permission {
code String @id @db.VarChar(64)
module String @db.VarChar(32)
moduleLabel String @map("module_label") @db.VarChar(32)
name String @db.VarChar(64)
description String @default("") @db.VarChar(255)
isSpecial Boolean @default(false) @map("is_special")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([module, sortOrder])
@@map("permissions")
}
/// 角色数据范围(A2-BE-04):六类范围之一 + 可选授权对象(当前只有「指定客户组」用客户组 id)。
/// 一行 = 一个范围类型的一个授权对象;无对象类型(全部 / 本部门 / 负责客户 / 本人创建)只存一行 target_id = NULL。
/// 解析后的可见客户集合由 CustomerDirectory 端口计算,不落库(A3 前为空集)。
model DataScope {
id String @id @db.Char(26)
roleId String @map("role_id") @db.Char(26)
scopeType String @map("scope_type") @db.VarChar(32)
targetId String? @map("target_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([roleId, scopeType, targetId])
@@index([roleId])
@@index([scopeType, targetId])
@@map("data_scopes")
}
/// 刷新令牌:jti 作主键、只存哈希;旋转时旧 token 立刻吊销并记录 replaced_by_id
model RefreshToken {
id String @id @db.Char(26)
+194 -62
View File
@@ -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();