feat(server): 试运行部署与运营基线(E13/E14/E17/E19/E20/E21)
- 增加 deploy/trialrun:Docker Compose + env 模板 + runbook + 发布/回滚 checklist - 新增 DB/文件存储备份恢复脚本(backup/restore) - 新增 system.users 运营接口:创建/启用禁用/重置密码/分配角色 + 审计 - 新增 seed:trialrun 引导管理员账号(一次性口令) - 补齐 SIGTERM/SIGINT 优雅停机(关闭 job/HTTP/DB)
这个提交包含在:
@@ -0,0 +1,181 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { hashPassword } = require("../utils/crypto");
|
||||
const { validatePassword } = require("../utils/passwordPolicy");
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`Missing required env: ${name}`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async function ensureDepartment(prisma, payload) {
|
||||
const now = new Date();
|
||||
const existing = await prisma.department.findFirst({
|
||||
where: {
|
||||
name: payload.name,
|
||||
parent_id: payload.parent_id ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return prisma.department.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
parent_id: payload.parent_id ?? null,
|
||||
archived_at: null,
|
||||
updated_at: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.department.create({
|
||||
data: {
|
||||
name: payload.name,
|
||||
parent_id: payload.parent_id ?? null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
function generateOneTimePassword() {
|
||||
// 确保满足 passwordPolicy:>=12 + 小写 + 大写 + 数字
|
||||
const suffix = crypto.randomBytes(6).toString("hex");
|
||||
return `TrialrunA1${suffix}`;
|
||||
}
|
||||
|
||||
async function ensureAdminEmployee(prisma, params) {
|
||||
const now = new Date();
|
||||
|
||||
const existing = await prisma.employee.findFirst({
|
||||
where: { OR: [{ email: params.email }, { phone: params.phone }] },
|
||||
select: { id: true, email: true, phone: true, status: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.employee.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.employeeRole.deleteMany({
|
||||
where: { employee_id: existing.id },
|
||||
});
|
||||
|
||||
await prisma.employeeRole.createMany({
|
||||
data: [
|
||||
{ employee_id: existing.id, role_code: "admin", created_at: now, created_by: null },
|
||||
{ employee_id: existing.id, role_code: "employee", created_at: now, created_by: null },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return { employeeId: existing.id, created: false, password: null };
|
||||
}
|
||||
|
||||
const password = params.password || generateOneTimePassword();
|
||||
const errors = validatePassword(password);
|
||||
if (errors.length) {
|
||||
throw new Error(`Invalid TRIALRUN_ADMIN_PASSWORD: ${errors.join("; ")}`);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
employee_no: params.employeeNo,
|
||||
name: params.name,
|
||||
phone: params.phone,
|
||||
email: params.email,
|
||||
department_id: params.departmentId,
|
||||
title: params.title,
|
||||
direct_manager_id: null,
|
||||
hire_date: params.hireDate,
|
||||
status: "active",
|
||||
bank_account_encrypted: "ENC_TRIALRUN",
|
||||
bank_name: "TRIALRUN_BANK",
|
||||
password_hash: passwordHash,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.employeeRole.createMany({
|
||||
data: [
|
||||
{ employee_id: employee.id, role_code: "admin", created_at: now, created_by: null },
|
||||
{ employee_id: employee.id, role_code: "employee", created_at: now, created_by: null },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return { employeeId: employee.id, created: true, password };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const adminEmail = requiredEnv("TRIALRUN_ADMIN_EMAIL");
|
||||
const adminPhone = requiredEnv("TRIALRUN_ADMIN_PHONE");
|
||||
const adminPassword = process.env.TRIALRUN_ADMIN_PASSWORD?.trim() || "";
|
||||
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
const root = await ensureDepartment(prisma, { name: "公司", parent_id: null });
|
||||
const management = await ensureDepartment(prisma, { name: "管理部", parent_id: root.id });
|
||||
|
||||
const result = await ensureAdminEmployee(prisma, {
|
||||
email: adminEmail,
|
||||
phone: adminPhone,
|
||||
password: adminPassword || null,
|
||||
employeeNo: process.env.TRIALRUN_ADMIN_EMPLOYEE_NO ?? "T0000",
|
||||
name: process.env.TRIALRUN_ADMIN_NAME ?? "Trialrun Admin",
|
||||
title: process.env.TRIALRUN_ADMIN_TITLE ?? "系统管理员",
|
||||
departmentId: management.id,
|
||||
hireDate: new Date(process.env.TRIALRUN_ADMIN_HIRE_DATE ?? "2020-01-01"),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Seeded trialrun admin:");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- email: ${adminEmail}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- phone: ${adminPhone}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- created: ${result.created}`);
|
||||
if (result.password) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- one-time password: ${result.password}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(" (请立刻保存,并在首次登录后由管理员重置/更换密码)");
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- password: unchanged");
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户