Files
lingxi/prisma/seed.ts
T
Stev_Wang 4d9eb980af
ci / 质量门禁 (push) Canceled after 0s
feat(identity): 完成 A1 身份认证与用户管理全栈落地
背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
2026-11-03 00:03:33 +08:00

495 lines
13 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.
// 灵犀 · 种子数据(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<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;
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<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 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: '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<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 seedDepartments();
await seedRoles();
await seedUsers();
await seedDepartmentLeaders();
}
void main()
.catch((error: unknown) => {
console.error('[seed] 失败:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});