// 灵犀 · 种子数据(A1) // 原则: // 1) 幂等:按业务唯一键 upsert,重复执行不重复插入;已存在的密码 / 人工调整值不被覆盖; // 2) 角色-权限点、用户-角色为种子可重建映射:先清后建(限定在种子角色 / 种子用户范围内); // 3) 超管、部门、示例用户口径见 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 { config as loadDotenv } from 'dotenv'; 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(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; permissions: string[]; } const ROLES: SeedRole[] = [ { code: 'super_admin', name: '超级管理员', 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[], }, { code: 'platform_admin', name: '平台管理员', description: '组织、角色与平台配置管理', isSystem: false, permissions: [ 'user:view', 'user:manage', 'role:manage', 'system:configure', 'audit:read', 'industry:manage', 'dict:manage', 'form:design', 'customer:read', ], }, { code: 'material_operator', name: '素材运营', description: '素材生产、快筛、导出与归档', isSystem: false, permissions: [ 'script:read', 'script:write', 'material:read', 'material:write', 'material:render', 'asset:read', 'asset:write', 'delivery:read', ], }, { code: 'designer', name: '设计师', description: '素材设计与资产维护', isSystem: false, permissions: ['material:read', 'material:write', 'asset:read', 'asset:write'], }, { code: 'copywriter', name: '文案策划', description: '脚本撰写与要素卡维护', isSystem: false, permissions: ['script:read', 'script:write'], }, { code: 'reviewer', name: '审核员', description: '脚本与素材审核', isSystem: false, permissions: ['script:read', 'script:review', 'material:read', 'material:review'], }, { code: 'ai_engineer', name: 'AI 工程师', description: '模型接入与生成链路维护', isSystem: false, permissions: ['material:read', 'material:render', 'system:configure'], }, { code: 'account_owner', name: '客户负责人', description: '客户资料查看、脚本审核与交付验收', isSystem: false, permissions: ['customer:read', 'customer:manage', 'delivery:read', 'script:review'], }, ]; 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 { 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 { 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 seedRoles(): Promise { 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: 'ACTIVE', }, }); await prisma.rolePermission.deleteMany({ where: { roleId: role.id } }); await prisma.rolePermission.createMany({ data: item.permissions.map((permissionCode) => ({ roleId: role.id, permissionCode })), skipDuplicates: true, }); } console.log(`[seed] roles 就绪:${String(ROLES.length)} 个 + 权限点映射(幂等)`); } async function seedUsers(): Promise { 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 { 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 { await seedConfigs(); 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(); });