ci / 质量门禁 (push) Canceled after 0s
背景: - 开发计划 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
495 lines
13 KiB
TypeScript
495 lines
13 KiB
TypeScript
// 灵犀 · 种子数据(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();
|
||
});
|