Files
lingxi/prisma/seed.ts
T
Stev_Wang 61894df401 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 全绿。
2026-11-03 12:26:51 +08:00

627 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 灵犀 · 种子数据(A2)
// 原则:
// 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖;
// 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 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)) {
loadDotenv({ path });
}
}
const prisma = new PrismaClient();
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
function ulid(): string {
let time = Date.now();
const timeChars = new Array<string>(10);
for (let i = 9; i >= 0; i -= 1) {
timeChars[i] = ENCODING[time % 32] ?? '0';
time = Math.floor(time / 32);
}
const random = randomBytes(16);
const randomChars = Array.from(random, (byte) => ENCODING[byte & 31] ?? '0');
return `${timeChars.join('')}${randomChars.join('')}`;
}
/** 示例账号统一初始密码:lingxi2026(Argon2id,首登强制修改) */
const SEED_PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
interface SeedConfig {
key: string;
value: string | number | boolean;
valueType: 'string' | 'number' | 'boolean';
description: string;
}
const SYSTEM_CONFIGS: SeedConfig[] = [
{
key: 'platform.name',
value: '灵犀',
valueType: 'string',
description: '平台名称(展示用)',
},
{
key: 'platform.export.signed_url_ttl_seconds',
value: 900,
valueType: 'number',
description: '导出产物签名 URL 有效期(秒),短时效',
},
{
key: 'platform.export.archive_retention_days',
value: 3,
valueType: 'number',
description: '导出打包产物默认保留天数',
},
{
key: 'platform.upload.max_upload_mb',
value: 500,
valueType: 'number',
description: '单文件上传上限(MB)',
},
{
key: 'platform.page.default_limit',
value: 20,
valueType: 'number',
description: '列表默认每页数量',
},
{
key: 'platform.ai.cost_soft_limit_ratio',
value: 0.8,
valueType: 'number',
description: 'AI 成本软上限触发比例',
},
{
key: 'platform.ai.cost_hard_limit_ratio',
value: 1,
valueType: 'number',
description: 'AI 成本硬上限触发比例(达到即拒绝新任务)',
},
];
interface SeedDepartment {
code: string;
name: string;
parentCode: string | null;
leaderAccount: string | null;
sortOrder: number;
}
const DEPARTMENTS: SeedDepartment[] = [
{
code: 'content',
name: '内容生产中心',
parentCode: null,
leaderAccount: 'chen.mo',
sortOrder: 1,
},
{ code: 'delivery', name: '交付中心', parentCode: null, leaderAccount: 'gao.cen', sortOrder: 2 },
{
code: 'tech',
name: '平台技术部',
parentCode: null,
leaderAccount: 'shen.zhiyuan',
sortOrder: 3,
},
{
code: 'content-g1',
name: '素材一组',
parentCode: 'content',
leaderAccount: null,
sortOrder: 1,
},
{
code: 'content-g2',
name: '素材二组',
parentCode: 'content',
leaderAccount: null,
sortOrder: 2,
},
{ code: 'content-g3', name: '设计组', parentCode: 'content', leaderAccount: null, sortOrder: 3 },
{
code: 'delivery-cs',
name: '客户成功组',
parentCode: 'delivery',
leaderAccount: null,
sortOrder: 1,
},
{ code: 'tech-ai', name: 'AI 工程组', parentCode: 'tech', leaderAccount: null, sortOrder: 1 },
{ code: 'tech-ops', name: '平台运维组', parentCode: 'tech', leaderAccount: null, sortOrder: 2 },
];
interface SeedRole {
code: string;
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: '内置角色,拥有系统全部权限',
isSystem: true,
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',
name: '平台管理员',
description: '组织、角色与平台配置管理',
isSystem: false,
permissions: [
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',
name: '素材运营',
description: '素材生产、快筛、导出与归档',
isSystem: false,
permissions: [
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: [
Permission.MATERIAL_READ,
Permission.MATERIAL_WRITE,
Permission.ASSET_READ,
Permission.ASSET_WRITE,
],
dataScope: { type: 'owned_customers' },
},
{
code: 'copywriter',
name: '文案策划',
description: '脚本撰写与要素卡维护',
isSystem: false,
permissions: [Permission.SCRIPT_READ, Permission.SCRIPT_WRITE],
dataScope: { type: 'department' },
},
{
code: 'reviewer',
name: '审核员',
description: '脚本与素材审核',
isSystem: false,
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: [
Permission.MATERIAL_READ,
Permission.MATERIAL_RENDER,
Permission.SYSTEM_CONFIGURE,
],
specialPermissions: [Permission.AI_CONFIGURE],
dataScope: { type: 'all' },
},
{
code: 'account_owner',
name: '客户负责人',
description: '客户资料、脚本审核与交付验收',
isSystem: false,
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' },
},
];
interface SeedUser {
name: string;
account: string;
phone: string;
jobTitle: string;
departmentCode: string;
roleCodes: string[];
status: UserStatus;
}
const USERS: SeedUser[] = [
{
name: '灵犀管理员',
account: 'admin',
phone: '13000000001',
jobTitle: '平台管理员',
departmentCode: 'tech',
roleCodes: ['super_admin'],
status: 'ACTIVE',
},
{
name: '陈默',
account: 'chen.mo',
phone: '13866202046',
jobTitle: '内容生产中心主管',
departmentCode: 'content-g1',
roleCodes: ['material_operator', 'account_owner'],
status: 'ACTIVE',
},
{
name: '林清',
account: 'lin.qing',
phone: '13912347712',
jobTitle: '设计师',
departmentCode: 'content-g3',
roleCodes: ['designer'],
status: 'ACTIVE',
},
{
name: '周岸',
account: 'zhou.an',
phone: '13755205520',
jobTitle: '文案策划',
departmentCode: 'content-g2',
roleCodes: ['copywriter'],
status: 'ACTIVE',
},
{
name: '赵一诺',
account: 'zhao.yinuo',
phone: '13633183188',
jobTitle: '审核员',
departmentCode: 'content-g2',
roleCodes: ['reviewer'],
status: 'ACTIVE',
},
{
name: '高岑',
account: 'gao.cen',
phone: '18644024402',
jobTitle: '交付中心主管',
departmentCode: 'delivery-cs',
roleCodes: ['account_owner'],
status: 'ACTIVE',
},
{
name: '苏禾',
account: 'su.he',
phone: '18877657765',
jobTitle: 'AI 工程师',
departmentCode: 'tech-ai',
roleCodes: ['ai_engineer'],
status: 'ACTIVE',
},
{
name: '沈知远',
account: 'shen.zhiyuan',
phone: '13300000071',
jobTitle: '平台技术部主管',
departmentCode: 'tech',
roleCodes: ['super_admin'],
status: 'ACTIVE',
},
{
name: '江岚',
account: 'jiang.lan',
phone: '15933203320',
jobTitle: '平台管理员',
departmentCode: 'tech-ops',
roleCodes: ['platform_admin'],
status: 'ACTIVE',
},
{
name: '唐嘉',
account: 'tang.jia',
phone: '13566416641',
jobTitle: '素材运营',
departmentCode: 'content-g1',
roleCodes: ['material_operator'],
status: 'DISABLED',
},
{
name: '何岸',
account: 'he.an',
phone: '18711931193',
jobTitle: '客户负责人',
departmentCode: 'delivery-cs',
roleCodes: ['account_owner'],
status: 'DISABLED',
},
];
async function seedConfigs(): Promise<void> {
for (const item of SYSTEM_CONFIGS) {
await prisma.systemConfig.upsert({
where: { configKey: item.key },
update: {},
create: {
id: ulid(),
configKey: item.key,
configValue: item.value,
valueType: item.valueType,
description: item.description,
},
});
}
console.log(`[seed] system_configs 就绪:${String(SYSTEM_CONFIGS.length)} 项(幂等)`);
}
async function seedDepartments(): Promise<void> {
for (const item of DEPARTMENTS) {
const parent =
item.parentCode === null
? null
: await prisma.department.findUnique({ where: { code: item.parentCode } });
await prisma.department.upsert({
where: { code: item.code },
update: {},
create: {
id: ulid(),
code: item.code,
name: item.name,
parentId: parent?.id ?? null,
sortOrder: item.sortOrder,
},
});
}
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({
where: { code: item.code },
update: {},
create: {
id: ulid(),
code: item.code,
name: item.name,
description: item.description,
isSystem: item.isSystem,
status: item.status ?? 'ACTIVE',
},
});
await seedRoleAccess(role.id, item);
}
console.log(
`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点 / 数据范围映射(幂等,特殊权限按原型显式授予)`,
);
}
async function seedUsers(): Promise<void> {
for (const item of USERS) {
const department = await prisma.department.findUnique({ where: { code: item.departmentCode } });
if (department === null) {
throw new Error(`部门不存在:${item.departmentCode}`);
}
const user = await prisma.user.upsert({
where: { account: item.account },
update: {},
create: {
id: ulid(),
name: item.name,
account: item.account,
email: `${item.account}@lingxi.cn`,
phone: item.phone,
passwordHash: SEED_PASSWORD_HASH,
departmentId: department.id,
jobTitle: item.jobTitle,
status: item.status,
mustChangePassword: true,
...(item.status === 'DISABLED' ? { disabledAt: new Date() } : {}),
},
});
await prisma.userRole.deleteMany({ where: { userId: user.id } });
for (const code of item.roleCodes) {
const role = await prisma.role.findUnique({ where: { code } });
if (role === null) throw new Error(`角色不存在:${code}`);
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
}
}
console.log(
`[seed] users 就绪:${String(USERS.length)} 个(幂等,初始密码 lingxi2026,首登强制修改)`,
);
}
async function seedDepartmentLeaders(): Promise<void> {
for (const item of DEPARTMENTS) {
if (item.leaderAccount === null) continue;
const [department, leader] = await Promise.all([
prisma.department.findUnique({ where: { code: item.code } }),
prisma.user.findUnique({ where: { account: item.leaderAccount } }),
]);
if (department !== null && leader !== null) {
await prisma.department.update({
where: { id: department.id },
data: { leaderUserId: leader.id },
});
}
}
console.log('[seed] 部门负责人就绪(幂等)');
}
async function main(): Promise<void> {
await seedConfigs();
await seedPermissions();
await seedDepartments();
await seedRoles();
await seedUsers();
await seedDepartmentLeaders();
}
void main()
.catch((error: unknown) => {
console.error('[seed] 失败:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});