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)
这个提交包含在:
2026-02-09 01:12:49 +08:00
父节点 89c0ed2ec1
当前提交 4223d81eb4
共修改 19 个文件,包含 1385 行新增和 1 行删除
+562
查看文件
@@ -0,0 +1,562 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { hashPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
const roleCodeSchema = z.enum([
"employee",
"manager",
"ceo",
"finance",
"finance_mgr",
"administration",
"hr",
"admin",
]);
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["active", "archived"]).optional(),
q: z.string().max(100).optional(),
});
const createBodySchema = z.object({
employeeNo: z.string().min(1).max(50),
name: z.string().min(1).max(100),
phone: z.string().min(6).max(30),
email: z.string().email().max(200),
departmentId: z.string().uuid().optional(),
title: z.string().min(1).max(100).optional(),
directManagerId: z.string().uuid().nullable().optional(),
hireDate: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/).optional(),
roles: z.array(roleCodeSchema).min(1).optional(),
password: z.string().min(1),
});
const setRolesBodySchema = z.object({
roles: z.array(roleCodeSchema).min(1),
});
const resetPasswordBodySchema = z.object({
newPassword: z.string().min(1).optional(),
});
function parseUserId(params) {
const parsed = z.string().uuid().safeParse(params?.id);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
function parseListQuery(query) {
const parsed = listQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status,
q: parsed.data.q?.trim() ?? "",
};
}
function parseCreateBody(body) {
const parsed = createBodySchema.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const errors = validatePassword(parsed.data.password);
if (errors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { password: errors },
});
}
return {
...parsed.data,
roles: parsed.data.roles ?? ["employee"],
};
}
function mapEmployeeSummary(row) {
return {
id: row.id,
employeeNo: row.employee_no,
name: row.name,
phone: row.phone,
email: row.email,
status: row.status,
departmentId: row.department_id,
title: row.title,
roleCodes: row.roles.map((item) => item.role_code),
createdAt: row.created_at?.toISOString?.() ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
};
}
function normalizeUniqueErrorDetails(error) {
const meta = error?.meta ?? {};
const target = meta.target ?? meta?.cause?.constraint?.fields ?? null;
const fields = Array.isArray(target) ? target : typeof target === "string" ? [target] : [];
const details = {};
for (const field of fields) {
if (typeof field === "string" && field.trim()) {
details[field] = "duplicate";
}
}
return Object.keys(details).length ? details : { unique: "duplicate" };
}
async function usersRoutes(fastify) {
fastify.get(
"/users",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const query = parseListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const q = query.q;
const where = {
...(query.status ? { status: query.status } : {}),
...(q
? {
OR: [
{ employee_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
{ phone: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.employee.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
department_id: true,
title: true,
created_at: true,
updated_at: true,
roles: { select: { role_code: true } },
},
}),
fastify.prisma.employee.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map(mapEmployeeSummary),
});
},
);
fastify.post(
"/users",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const body = parseCreateBody(request.body);
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
const departmentId = body.departmentId ?? request.employee.department_id;
const department = await fastify.prisma.department.findUnique({
where: { id: departmentId },
select: { id: true },
});
if (!department) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { departmentId: "not_found" },
});
}
const passwordHash = await hashPassword(body.password);
const now = new Date();
let created = null;
try {
created = await fastify.prisma.$transaction(async (tx) => {
const employee = await tx.employee.create({
data: {
employee_no: body.employeeNo,
name: body.name,
phone: body.phone,
email: body.email,
department_id: departmentId,
title: body.title ?? "员工",
direct_manager_id: body.directManagerId ?? null,
hire_date: hireDate,
status: "active",
bank_account_encrypted: "ENC_TBD",
bank_name: "TBD",
password_hash: passwordHash,
created_at: now,
created_by: request.employee.id,
updated_at: now,
updated_by: request.employee.id,
},
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
department_id: true,
title: true,
created_at: true,
updated_at: true,
},
});
await tx.employeeRole.createMany({
data: body.roles.map((roleCode) => ({
employee_id: employee.id,
role_code: roleCode,
created_at: now,
created_by: request.employee.id,
})),
skipDuplicates: true,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_CREATED",
targetType: "employee",
targetId: employee.id,
extra: {
employeeNo: employee.employee_no,
email: employee.email,
phone: employee.phone,
roles: body.roles,
},
}),
});
return employee;
});
} catch (error) {
if (error?.code === "P2002") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: normalizeUniqueErrorDetails(error),
});
}
throw error;
}
const roles = await fastify.prisma.employeeRole.findMany({
where: { employee_id: created.id },
select: { role_code: true },
});
return sendData(reply, request, {
...mapEmployeeSummary({ ...created, roles }),
});
},
);
fastify.post(
"/users/:id/disable",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const userId = parseUserId(request.params);
if (userId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { user: "self_disable_not_allowed" },
});
}
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.employee.update({
where: { id: userId },
data: {
status: "archived",
archived_at: now,
token_version: { increment: 1 },
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
token_version: true,
},
});
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_DISABLED",
targetType: "employee",
targetId: userId,
extra: {},
}),
});
return updated;
});
return sendData(reply, request, {
id: employee.id,
status: employee.status,
});
},
);
fastify.post(
"/users/:id/enable",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const userId = parseUserId(request.params);
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.employee.update({
where: { id: userId },
data: {
status: "active",
archived_at: null,
token_version: { increment: 1 },
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
token_version: true,
},
});
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_ENABLED",
targetType: "employee",
targetId: userId,
extra: {},
}),
});
return updated;
});
return sendData(reply, request, {
id: employee.id,
status: employee.status,
});
},
);
fastify.post(
"/users/:id/reset-password",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const userId = parseUserId(request.params);
const parsed = resetPasswordBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const newPassword =
typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim()
? parsed.data.newPassword.trim()
: null;
const oneTimePassword = newPassword ?? `ResetA1${crypto.randomBytes(6).toString("hex")}`;
const errors = validatePassword(oneTimePassword);
if (errors.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { newPassword: errors },
});
}
const passwordHash = await hashPassword(oneTimePassword);
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
await tx.employee.update({
where: { id: userId },
data: {
password_hash: passwordHash,
token_version: { increment: 1 },
updated_by: request.employee.id,
},
});
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_PASSWORD_RESET",
targetType: "employee",
targetId: userId,
extra: {},
}),
});
});
return sendData(reply, request, {
id: userId,
oneTimePassword: oneTimePassword,
});
},
);
fastify.post(
"/users/:id/roles",
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const userId = parseUserId(request.params);
if (userId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { user: "self_role_change_not_allowed" },
});
}
const parsed = setRolesBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const roles = parsed.data.roles;
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
await tx.employeeRole.deleteMany({
where: { employee_id: userId },
});
await tx.employeeRole.createMany({
data: roles.map((roleCode) => ({
employee_id: userId,
role_code: roleCode,
created_at: now,
created_by: request.employee.id,
})),
skipDuplicates: true,
});
await tx.employee.update({
where: { id: userId },
data: {
token_version: { increment: 1 },
updated_by: request.employee.id,
},
});
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_ROLES_SET",
targetType: "employee",
targetId: userId,
extra: { roles },
}),
});
});
return sendData(reply, request, {
id: userId,
roleCodes: roles,
});
},
);
}
module.exports = usersRoutes;