audit_logs 按月 RANGE 分区、复合主键与 6 组索引;ensure_partitions 幂等建分区;UPDATE/DELETE/TRUNCATE 触发器与 lingxi_app 最小权限;audit_export_jobs;种子新增 3 项配置与 6 条演示审计。
1096 lines
33 KiB
TypeScript
1096 lines
33 KiB
TypeScript
// 灵犀 · 种子数据(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 type { Prisma } from '@prisma/client';
|
||
import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client';
|
||
import { config as loadDotenv } from 'dotenv';
|
||
|
||
// 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移)
|
||
import {
|
||
SYSTEM_CONFIG_DEFINITIONS,
|
||
systemConfigValueType,
|
||
} from '../packages/shared/src/config/system-configs.js';
|
||
import {
|
||
findSodConflicts,
|
||
Permission,
|
||
PERMISSION_CATALOG,
|
||
splitPermissions,
|
||
} from '../packages/shared/src/permissions.js';
|
||
import dictSeed from './seed-data/dicts.json' with { type: 'json' };
|
||
|
||
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 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 definition of SYSTEM_CONFIG_DEFINITIONS) {
|
||
await prisma.systemConfig.upsert({
|
||
where: { configKey: definition.key },
|
||
// 只同步元数据,不覆盖人工调整过的值 / 版本
|
||
update: {
|
||
description: definition.description,
|
||
valueType: systemConfigValueType(definition),
|
||
isEditable: definition.editable,
|
||
},
|
||
create: {
|
||
id: ulid(),
|
||
configKey: definition.key,
|
||
configValue: definition.defaultValue,
|
||
valueType: systemConfigValueType(definition),
|
||
description: definition.description,
|
||
isEditable: definition.editable,
|
||
},
|
||
});
|
||
}
|
||
console.log(`[seed] system_configs 就绪:${String(SYSTEM_CONFIG_DEFINITIONS.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] 部门负责人就绪(幂等)');
|
||
}
|
||
|
||
/**
|
||
* A3 客户与行业种子(幂等):
|
||
* - 两级行业:电商(服饰鞋包 / 美妆个护)、金融(信贷);
|
||
* - 2 个客户(花间集、素研)与 1 个客户组(电商核心组);
|
||
* - 花间集 3 条客户级字段:已生效 2(1 条进入生成 + 1 条仅检索)、待审批 1。
|
||
* 不覆盖人工调整值:按业务唯一键查找,存在则只补齐缺失关系。
|
||
*/
|
||
/* eslint-disable max-lines-per-function -- A3 种子按行业 / 客户 / 客户组 / 字段顺序幂等写入 */
|
||
async function seedClient(): Promise<void> {
|
||
const admin = await prisma.user.findUnique({ where: { account: 'admin' }, select: { id: true } });
|
||
const chen = await prisma.user.findUnique({
|
||
where: { account: 'chen.mo' },
|
||
select: { id: true },
|
||
});
|
||
const lin = await prisma.user.findUnique({
|
||
where: { account: 'lin.qing' },
|
||
select: { id: true },
|
||
});
|
||
const jiang = await prisma.user.findUnique({
|
||
where: { account: 'jiang.lan' },
|
||
select: { id: true },
|
||
});
|
||
if (admin === null || chen === null || lin === null || jiang === null) {
|
||
throw new Error('A3 种子依赖 admin / chen.mo / lin.qing / jiang.lan 账号,请先执行 A1 种子');
|
||
}
|
||
const adminId = admin.id;
|
||
|
||
async function upsertIndustry(
|
||
name: string,
|
||
parentId: string | null,
|
||
level: number,
|
||
sortOrder: number,
|
||
remark: string,
|
||
): Promise<string> {
|
||
const existing = await prisma.industry.findFirst({
|
||
where: { name, parentId },
|
||
select: { id: true },
|
||
});
|
||
if (existing !== null) return existing.id;
|
||
const id = ulid();
|
||
await prisma.industry.create({
|
||
data: { id, name, parentId, level, sortOrder, remark, status: 'ACTIVE' },
|
||
});
|
||
return id;
|
||
}
|
||
|
||
const ec = await upsertIndustry('电商', null, 1, 10, '电商行业客户,覆盖服饰、美妆等类目');
|
||
const apparel = await upsertIndustry('服饰鞋包', ec, 2, 10, '服饰、鞋履、箱包类目');
|
||
const beauty = await upsertIndustry('美妆个护', ec, 2, 20, '护肤、彩妆、个护类目');
|
||
const fin = await upsertIndustry('金融', null, 1, 20, '金融行业客户,合规要求高');
|
||
await upsertIndustry('信贷', fin, 2, 10, '消费贷、经营贷、信用卡');
|
||
|
||
async function upsertCustomer(input: {
|
||
name: string;
|
||
shortName: string;
|
||
industryId: string;
|
||
ownerUserId: string;
|
||
contactName: string;
|
||
contactPhone: string;
|
||
remark: string;
|
||
preferences: Prisma.InputJsonValue;
|
||
customFields: Prisma.InputJsonValue;
|
||
}): Promise<string> {
|
||
const existing = await prisma.customer.findFirst({
|
||
where: { industryId: input.industryId, name: input.name, deletedAt: null },
|
||
select: { id: true },
|
||
});
|
||
if (existing !== null) return existing.id;
|
||
const id = ulid();
|
||
await prisma.customer.create({
|
||
data: {
|
||
id,
|
||
industryId: input.industryId,
|
||
name: input.name,
|
||
shortName: input.shortName,
|
||
status: 'ACTIVE',
|
||
ownerUserId: input.ownerUserId,
|
||
contactName: input.contactName,
|
||
contactPhone: input.contactPhone,
|
||
preferences: input.preferences,
|
||
customFields: input.customFields,
|
||
remark: input.remark,
|
||
createdBy: adminId,
|
||
},
|
||
});
|
||
return id;
|
||
}
|
||
|
||
const huajianji = await upsertCustomer({
|
||
name: '花间集',
|
||
shortName: 'HJ',
|
||
industryId: apparel,
|
||
ownerUserId: chen.id,
|
||
contactName: '林晓',
|
||
contactPhone: '138****2210',
|
||
remark: '秋冬新品主推,素材需保留品牌金色描边。',
|
||
preferences: {
|
||
naming: '客户名_方向_日期_序号',
|
||
directory: '花间集/2026/秋冬',
|
||
specs: ['1080×1920', '1080×1080'],
|
||
taboo: ['绝对化用语', '未授权肖像'],
|
||
},
|
||
customFields: [
|
||
{ key: '门店编号', value: '沪-012' },
|
||
{ key: '内部产品代码', value: 'HJ-2026' },
|
||
],
|
||
});
|
||
const suyan = await upsertCustomer({
|
||
name: '素研',
|
||
shortName: 'SY',
|
||
industryId: beauty,
|
||
ownerUserId: lin.id,
|
||
contactName: '周航',
|
||
contactPhone: '139****8832',
|
||
remark: '以成分卖点为主,需规避医疗功效表述。',
|
||
preferences: {
|
||
naming: '客户名_产品线_日期_序号',
|
||
directory: '素研/2026/Q4',
|
||
specs: ['1080×1920'],
|
||
taboo: ['医疗功效表述'],
|
||
},
|
||
customFields: [{ key: '门店编号', value: '杭-008' }],
|
||
});
|
||
|
||
async function upsertGroup(): Promise<void> {
|
||
const existing = await prisma.customerGroup.findUnique({
|
||
where: { name: '电商核心组' },
|
||
select: { id: true },
|
||
});
|
||
const groupId = existing?.id ?? ulid();
|
||
if (existing === null) {
|
||
await prisma.customerGroup.create({
|
||
data: {
|
||
id: groupId,
|
||
name: '电商核心组',
|
||
description: '电商行业核心客户,作为数据范围授权单位',
|
||
createdBy: adminId,
|
||
},
|
||
});
|
||
}
|
||
await prisma.customerGroupMember.createMany({
|
||
data: [huajianji, suyan].map((customerId) => ({ groupId, customerId })),
|
||
skipDuplicates: true,
|
||
});
|
||
}
|
||
await upsertGroup();
|
||
|
||
const fieldSeeds = [
|
||
{
|
||
customerId: huajianji,
|
||
name: '商品系列',
|
||
code: 'series',
|
||
type: 'select',
|
||
options: ['秋冬', '春夏'],
|
||
purpose: '卖点与脚本分组,按系列匹配脚本模板',
|
||
entersGeneration: true,
|
||
status: 'ACTIVE' as const,
|
||
approverUserId: jiang.id,
|
||
},
|
||
{
|
||
customerId: huajianji,
|
||
name: '门店编号',
|
||
code: 'store_code',
|
||
type: 'text',
|
||
options: [],
|
||
purpose: '门店素材检索与报表拆分',
|
||
entersGeneration: false,
|
||
status: 'ACTIVE' as const,
|
||
approverUserId: jiang.id,
|
||
},
|
||
{
|
||
customerId: huajianji,
|
||
name: '活动编码',
|
||
code: 'campaign_code',
|
||
type: 'text',
|
||
options: [],
|
||
purpose: '活动素材命名与检索,用于区分不同投放批次',
|
||
entersGeneration: true,
|
||
status: 'PENDING' as const,
|
||
approverUserId: null,
|
||
},
|
||
{
|
||
customerId: suyan,
|
||
name: '产品线',
|
||
code: 'product_line',
|
||
type: 'select',
|
||
options: ['精华', '面霜'],
|
||
purpose: '按产品线匹配脚本方向与卖点',
|
||
entersGeneration: true,
|
||
status: 'ACTIVE' as const,
|
||
approverUserId: jiang.id,
|
||
},
|
||
];
|
||
for (const field of fieldSeeds) {
|
||
const existing = await prisma.customerField.findUnique({
|
||
where: { customerId_code: { customerId: field.customerId, code: field.code } },
|
||
select: { id: true },
|
||
});
|
||
if (existing !== null) continue;
|
||
await prisma.customerField.create({
|
||
data: {
|
||
id: ulid(),
|
||
customerId: field.customerId,
|
||
name: field.name,
|
||
code: field.code,
|
||
type: field.type,
|
||
options: field.options,
|
||
purpose: field.purpose,
|
||
entersGeneration: field.entersGeneration,
|
||
status: field.status,
|
||
applicantUserId: chen.id,
|
||
approverUserId: field.approverUserId,
|
||
approvedAt: field.status === 'ACTIVE' ? new Date() : null,
|
||
},
|
||
});
|
||
}
|
||
console.log('[seed] A3 客户与行业就绪(幂等)');
|
||
}
|
||
/* eslint-enable max-lines-per-function */
|
||
|
||
interface DictSeedType {
|
||
code: string;
|
||
name: string;
|
||
group: string;
|
||
kind: 'tree' | 'list';
|
||
description: string;
|
||
industryScoped: boolean;
|
||
builtin: boolean;
|
||
status: 'active' | 'disabled';
|
||
sortOrder: number;
|
||
updatedBy: string;
|
||
}
|
||
|
||
interface DictSeedItem {
|
||
typeCode: string;
|
||
code: string;
|
||
name: string;
|
||
parentCode: string | null;
|
||
industryId: string | null;
|
||
sortOrder: number;
|
||
status: 'active' | 'disabled';
|
||
remark: string;
|
||
updatedBy: string;
|
||
}
|
||
|
||
const DICT_TYPES = dictSeed.types as DictSeedType[];
|
||
const DICT_ITEMS = dictSeed.items as DictSeedItem[];
|
||
|
||
/** 原型示例数据用行业 code 表示作用域;种子落库时映射为 A3 行业名称 */
|
||
const DICT_INDUSTRY_CODE_TO_NAME: Readonly<Record<string, string>> = {
|
||
ec: '电商',
|
||
ec_apparel: '服饰鞋包',
|
||
ec_beauty: '美妆个护',
|
||
fin: '金融',
|
||
fin_credit: '信贷',
|
||
local: '本地生活',
|
||
local_food: '餐饮到店',
|
||
};
|
||
|
||
/** A4 数据字典种子:13 类 / 96 条;行业作用域用 A3 的行业 ID 解析;幂等,不覆盖人工调整 */
|
||
// eslint-disable-next-line max-lines-per-function -- 类型 + 条目两段种子共用行业映射,集中便于阅读
|
||
async function seedDicts(): Promise<void> {
|
||
const industries = await prisma.industry.findMany({
|
||
select: { id: true, name: true, parentId: true },
|
||
});
|
||
const industryByName = new Map(industries.map((industry) => [industry.name, industry.id]));
|
||
const typeIdByCode = new Map<string, string>();
|
||
|
||
for (const item of DICT_TYPES) {
|
||
const status = item.status === 'active' ? 'ACTIVE' : 'DISABLED';
|
||
const existing = await prisma.dictType.findUnique({
|
||
where: { code: item.code },
|
||
select: { id: true },
|
||
});
|
||
if (existing !== null) {
|
||
typeIdByCode.set(item.code, existing.id);
|
||
continue;
|
||
}
|
||
const id = ulid();
|
||
await prisma.dictType.create({
|
||
data: {
|
||
id,
|
||
code: item.code,
|
||
name: item.name,
|
||
groupKey: item.group,
|
||
kind: item.kind === 'tree' ? 'TREE' : 'LIST',
|
||
description: item.description,
|
||
industryScoped: item.industryScoped,
|
||
builtin: item.builtin,
|
||
status,
|
||
sortOrder: item.sortOrder,
|
||
updatedBy: item.updatedBy,
|
||
},
|
||
});
|
||
typeIdByCode.set(item.code, id);
|
||
}
|
||
|
||
for (const item of DICT_ITEMS) {
|
||
const typeId = typeIdByCode.get(item.typeCode);
|
||
if (typeId === undefined) continue;
|
||
const industryName =
|
||
item.industryId === null
|
||
? null
|
||
: (DICT_INDUSTRY_CODE_TO_NAME[item.industryId] ?? item.industryId);
|
||
const industryId = industryName === null ? null : (industryByName.get(industryName) ?? null);
|
||
const existing = await prisma.dictItem.findFirst({
|
||
where: { typeId, code: item.code, industryId },
|
||
select: { id: true },
|
||
});
|
||
if (existing !== null) continue;
|
||
await prisma.dictItem.create({
|
||
data: {
|
||
id: ulid(),
|
||
typeId,
|
||
code: item.code,
|
||
name: item.name,
|
||
parentCode: item.parentCode,
|
||
industryId,
|
||
sortOrder: item.sortOrder,
|
||
status: item.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||
remark: item.remark,
|
||
updatedBy: item.updatedBy,
|
||
},
|
||
});
|
||
}
|
||
|
||
console.log(
|
||
`[seed] A4 数据字典就绪:${String(DICT_TYPES.length)} 类 / ${String(DICT_ITEMS.length)} 条(幂等)`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A5 审计演示数据(幂等):固定 id + 当前时间附近,写入 6 条覆盖登录 / 客户 / 权限 / 配置 / 审计查看 / 导出。
|
||
* 注意:audit_logs 是 append-only(DB 层禁 UPDATE / DELETE),因此只能“存在即跳过”,不能 upsert 覆盖。
|
||
* 集成测试使用独立前缀与筛选条件,不受这些演示数据影响。
|
||
*/
|
||
const AUDIT_DEMO_IDS = [
|
||
'01HZZZZZZZZZZZZZZZZZZZZZ01',
|
||
'01HZZZZZZZZZZZZZZZZZZZZZ02',
|
||
'01HZZZZZZZZZZZZZZZZZZZZZ03',
|
||
'01HZZZZZZZZZZZZZZZZZZZZZ04',
|
||
'01HZZZZZZZZZZZZZZZZZZZZZ05',
|
||
'01HZZZZZZZZZZZZZZZZZZZZZ06',
|
||
] as const;
|
||
|
||
// eslint-disable-next-line max-lines-per-function, complexity -- 6 条演示数据集中定义便于核对
|
||
async function seedAuditDemos(): Promise<void> {
|
||
const admin = await prisma.user.findUnique({
|
||
where: { account: 'admin' },
|
||
select: { id: true, name: true, account: true },
|
||
});
|
||
if (admin === null) {
|
||
console.log('[seed] 跳过 A5 审计演示数据(admin 账号不存在)');
|
||
return;
|
||
}
|
||
const customer = await prisma.customer.findFirst({
|
||
where: { deletedAt: null },
|
||
orderBy: { createdAt: 'asc' },
|
||
select: { id: true, name: true },
|
||
});
|
||
const role = await prisma.role.findFirst({
|
||
where: { code: 'platform_admin' },
|
||
select: { id: true, name: true },
|
||
});
|
||
|
||
const now = Date.now();
|
||
const demos = [
|
||
{
|
||
id: AUDIT_DEMO_IDS[0],
|
||
occurredAt: new Date(now - 5 * 60_000),
|
||
actionCode: 'identity.auth.login',
|
||
category: 'auth',
|
||
targetType: 'user',
|
||
targetId: admin.id,
|
||
targetName: admin.name,
|
||
result: 'success',
|
||
httpMethod: 'POST',
|
||
httpPath: '/api/v1/auth/login',
|
||
traceId: 'seed-audit-login',
|
||
beforeJson: null,
|
||
afterJson: { account: admin.account },
|
||
changesJson: null,
|
||
},
|
||
{
|
||
id: AUDIT_DEMO_IDS[1],
|
||
occurredAt: new Date(now - 4 * 60_000),
|
||
actionCode: 'client.customer.update',
|
||
category: 'client',
|
||
targetType: 'customer',
|
||
targetId: customer?.id ?? null,
|
||
targetName: customer?.name ?? '示例客户',
|
||
customerId: customer?.id ?? null,
|
||
customerName: customer?.name ?? '示例客户',
|
||
result: 'success',
|
||
httpMethod: 'PATCH',
|
||
httpPath: customer === null ? '/api/v1/customers' : `/api/v1/customers/${customer.id}`,
|
||
traceId: 'seed-audit-customer',
|
||
beforeJson: { remark: '旧备注' },
|
||
afterJson: { remark: 'A5 审计演示:客户备注已更新' },
|
||
changesJson: null,
|
||
},
|
||
{
|
||
id: AUDIT_DEMO_IDS[2],
|
||
occurredAt: new Date(now - 3 * 60_000),
|
||
actionCode: 'identity.role.update',
|
||
category: 'permission',
|
||
targetType: 'role',
|
||
targetId: role?.id ?? null,
|
||
targetName: role?.name ?? '示例角色',
|
||
result: 'success',
|
||
httpMethod: 'PATCH',
|
||
httpPath: role === null ? '/api/v1/roles' : `/api/v1/roles/${role.id}`,
|
||
traceId: 'seed-audit-role',
|
||
beforeJson: { permissions: ['user:view'] },
|
||
afterJson: { permissions: ['user:view', 'role:view'] },
|
||
changesJson: { permissions: { added: ['role:view'], removed: [] } },
|
||
},
|
||
{
|
||
id: AUDIT_DEMO_IDS[3],
|
||
occurredAt: new Date(now - 2 * 60_000),
|
||
actionCode: 'platform.system_config.update',
|
||
category: 'config',
|
||
targetType: 'system_config',
|
||
targetId: 'platform.page.default_limit',
|
||
targetName: '列表默认条数',
|
||
result: 'success',
|
||
httpMethod: 'PUT',
|
||
httpPath: '/api/v1/system/configs',
|
||
traceId: 'seed-audit-config',
|
||
beforeJson: { 'platform.page.default_limit': 20 },
|
||
afterJson: { 'platform.page.default_limit': 20 },
|
||
changesJson: [{ key: 'platform.page.default_limit', oldValue: 20, newValue: 20 }],
|
||
},
|
||
{
|
||
id: AUDIT_DEMO_IDS[4],
|
||
occurredAt: new Date(now - 60_000),
|
||
actionCode: 'audit.log.view',
|
||
category: 'sensitive',
|
||
targetType: 'audit_log',
|
||
targetId: null,
|
||
targetName: null,
|
||
result: 'success',
|
||
httpMethod: 'GET',
|
||
httpPath: '/api/v1/audit-logs',
|
||
traceId: 'seed-audit-view',
|
||
beforeJson: null,
|
||
afterJson: null,
|
||
changesJson: null,
|
||
},
|
||
{
|
||
id: AUDIT_DEMO_IDS[5],
|
||
occurredAt: new Date(now - 30_000),
|
||
actionCode: 'audit.export',
|
||
category: 'export',
|
||
targetType: 'audit_export',
|
||
targetId: null,
|
||
targetName: '审计导出 CSV',
|
||
result: 'success',
|
||
httpMethod: 'POST',
|
||
httpPath: '/api/v1/audit-logs/export',
|
||
traceId: 'seed-audit-export',
|
||
beforeJson: null,
|
||
afterJson: { format: 'csv', filters: { result: 'failure' }, rowCount: 120 },
|
||
changesJson: null,
|
||
},
|
||
];
|
||
|
||
let created = 0;
|
||
for (const demo of demos) {
|
||
const existing = await prisma.auditLog.findFirst({
|
||
where: { id: demo.id },
|
||
select: { id: true },
|
||
});
|
||
if (existing !== null) continue;
|
||
await prisma.auditLog.create({
|
||
data: {
|
||
id: demo.id,
|
||
occurredAt: demo.occurredAt,
|
||
actorId: admin.id,
|
||
actorName: admin.name,
|
||
actorAccount: admin.account,
|
||
actionCode: demo.actionCode,
|
||
category: demo.category,
|
||
targetType: demo.targetType,
|
||
targetId: demo.targetId,
|
||
targetName: demo.targetName,
|
||
customerId: 'customerId' in demo ? demo.customerId : null,
|
||
customerName: 'customerName' in demo ? demo.customerName : null,
|
||
result: demo.result,
|
||
httpMethod: demo.httpMethod,
|
||
httpPath: demo.httpPath,
|
||
traceId: demo.traceId,
|
||
ip: '127.0.0.1',
|
||
userAgent: 'seed',
|
||
beforeJson: demo.beforeJson ?? undefined,
|
||
afterJson: demo.afterJson ?? undefined,
|
||
changesJson: demo.changesJson ?? undefined,
|
||
},
|
||
});
|
||
created += 1;
|
||
}
|
||
console.log(
|
||
`[seed] A5 审计演示数据就绪:新增 ${String(created)} 条 / 共 ${String(AUDIT_DEMO_IDS.length)} 条(幂等)`,
|
||
);
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
await seedConfigs();
|
||
await seedPermissions();
|
||
await seedDepartments();
|
||
await seedRoles();
|
||
await seedUsers();
|
||
await seedDepartmentLeaders();
|
||
await seedClient();
|
||
await seedDicts();
|
||
await seedAuditDemos();
|
||
}
|
||
|
||
void main()
|
||
.catch((error: unknown) => {
|
||
console.error('[seed] 失败:', error);
|
||
process.exitCode = 1;
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|