- workflow_instances 解耦 reimbursement FK:新增 reimbursement_id/leave_request_id + 回填迁移 - 新增 leave_requests 表与 /api/leave-requests 最小链路(create/submit) - workflow approve/reject/return 支持 leave 状态更新;transfer 非报销任务禁用 - seed-config 增加 leave workflow definition;更新 v3 CSV 状态推进
237 行
7.1 KiB
JavaScript
237 行
7.1 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const fs = require("node:fs/promises");
|
|
|
|
require("dotenv/config");
|
|
|
|
const fastify = require("fastify");
|
|
const cors = require("@fastify/cors");
|
|
const cookie = require("@fastify/cookie");
|
|
const multipart = require("@fastify/multipart");
|
|
const helmet = require("@fastify/helmet");
|
|
const rateLimit = require("@fastify/rate-limit");
|
|
const staticPlugin = require("@fastify/static");
|
|
|
|
const { getConfig } = require("./config");
|
|
const authPlugin = require("./plugins/auth");
|
|
const prismaPlugin = require("./plugins/prisma");
|
|
const authRoutes = require("./routes/auth");
|
|
const meRoutes = require("./routes/me");
|
|
const reimbursementRoutes = require("./routes/reimbursements");
|
|
const invoicePoolRoutes = require("./routes/invoicePool");
|
|
const workflowRoutes = require("./routes/workflow");
|
|
const leaveRoutes = require("./routes/leave");
|
|
const delegationRoutes = require("./routes/delegations");
|
|
const auditRoutes = require("./routes/audit");
|
|
const usersRoutes = require("./routes/users");
|
|
const departmentsRoutes = require("./routes/departments");
|
|
const employeesRoutes = require("./routes/employees");
|
|
const expenseTypesRoutes = require("./routes/expenseTypes");
|
|
const configReadRoutes = require("./routes/configRead");
|
|
const approvalRulesRoutes = require("./routes/approvalRules");
|
|
const notificationsRoutes = require("./routes/notifications");
|
|
const exportsRoutes = require("./routes/exports");
|
|
const { startJobs } = require("./jobs/jobSystem");
|
|
const { initMetrics } = require("./observability");
|
|
const { AppError } = require("./utils/AppError");
|
|
|
|
async function buildApp() {
|
|
const config = getConfig();
|
|
|
|
const app = fastify({
|
|
logger: {
|
|
redact: {
|
|
paths: [
|
|
"req.headers.authorization",
|
|
"req.headers.cookie",
|
|
"req.headers['set-cookie']",
|
|
"res.headers['set-cookie']",
|
|
"req.body.password",
|
|
"req.body.newPassword",
|
|
"req.body.code",
|
|
],
|
|
censor: "[REDACTED]",
|
|
},
|
|
},
|
|
trustProxy: true,
|
|
genReqId: () => crypto.randomUUID(),
|
|
});
|
|
|
|
app.decorate("config", config);
|
|
await fs.mkdir(config.storageDir, { recursive: true });
|
|
|
|
await app.register(cookie);
|
|
await app.register(multipart, {
|
|
attachFieldsToBody: false,
|
|
limits: {
|
|
fileSize: config.maxUploadSizeBytes,
|
|
files: 1,
|
|
},
|
|
});
|
|
|
|
const allowedOrigins = config.appOrigin
|
|
? config.appOrigin
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: null;
|
|
|
|
await app.register(cors, {
|
|
credentials: true,
|
|
origin: (origin, cb) => {
|
|
if (!origin) return cb(null, true);
|
|
if (!allowedOrigins) return cb(null, true);
|
|
if (allowedOrigins.includes(origin)) return cb(null, true);
|
|
return cb(new Error("CORS origin not allowed"), false);
|
|
},
|
|
});
|
|
|
|
await app.register(helmet, {
|
|
contentSecurityPolicy: false,
|
|
});
|
|
|
|
await app.register(rateLimit, {
|
|
global: false,
|
|
errorResponseBuilder: (request, context) => {
|
|
return new AppError({
|
|
code: "SYS_429",
|
|
message: "请求过于频繁,请稍后重试",
|
|
statusCode: context.statusCode,
|
|
details: {
|
|
retryAfter: context.after,
|
|
max: context.max,
|
|
ban: context.ban,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
if (config.serveClientDist) {
|
|
await app.register(staticPlugin, {
|
|
root: config.clientDistDir,
|
|
});
|
|
app.log.info({ dir: config.clientDistDir }, "Client dist static serving enabled");
|
|
}
|
|
|
|
app.addHook("onRequest", async (request, reply) => {
|
|
reply.header("x-request-id", request.id);
|
|
if (request.log && typeof request.log.child === "function") {
|
|
request.log = request.log.child({ requestId: request.id });
|
|
}
|
|
});
|
|
|
|
app.setNotFoundHandler(async (request, reply) => {
|
|
if (
|
|
config.serveClientDist &&
|
|
request.method === "GET" &&
|
|
!request.url.startsWith("/api") &&
|
|
typeof request.headers.accept === "string" &&
|
|
request.headers.accept.includes("text/html") &&
|
|
typeof reply.sendFile === "function"
|
|
) {
|
|
return reply.type("text/html").sendFile("index.html");
|
|
}
|
|
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
});
|
|
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error.code === "FST_REQ_FILE_TOO_LARGE") {
|
|
return reply.status(400).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
details: { file: "file_too_large" },
|
|
},
|
|
});
|
|
}
|
|
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: error.code,
|
|
message: error.message,
|
|
details: error.details ?? {},
|
|
},
|
|
});
|
|
}
|
|
|
|
app.log.error({ err: error, requestId: request.id }, "Unhandled error");
|
|
return reply.status(500).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: "SYS_001",
|
|
message: "服务器内部错误",
|
|
details: {},
|
|
},
|
|
});
|
|
});
|
|
|
|
await app.register(prismaPlugin);
|
|
await app.register(authPlugin);
|
|
|
|
app.get("/health", async (request, reply) => {
|
|
return reply.send({ requestId: request.id, data: { ok: true } });
|
|
});
|
|
|
|
app.get("/ready", async (request, reply) => {
|
|
try {
|
|
await app.prisma.$queryRaw`SELECT 1`;
|
|
return reply.send({ requestId: request.id, data: { ok: true } });
|
|
} catch (error) {
|
|
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
|
|
throw new AppError({
|
|
code: "SYS_503",
|
|
message: "Service Unavailable",
|
|
statusCode: 503,
|
|
details: { db: "unreachable" },
|
|
});
|
|
}
|
|
});
|
|
|
|
if (config.metricsEnabled) {
|
|
initMetrics(app);
|
|
app.get("/metrics", async (request, reply) => {
|
|
try {
|
|
await app.prisma.$queryRaw`SELECT 1`;
|
|
app.metrics.dbReady.set(1);
|
|
} catch {
|
|
app.metrics.dbReady.set(0);
|
|
}
|
|
|
|
const payload = await app.metrics.registry.metrics();
|
|
reply.header("content-type", app.metrics.registry.contentType);
|
|
return reply.send(payload);
|
|
});
|
|
app.log.info("Metrics endpoint enabled at /metrics");
|
|
}
|
|
|
|
await app.register(authRoutes, { prefix: "/api/auth" });
|
|
await app.register(meRoutes, { prefix: "/api" });
|
|
await app.register(reimbursementRoutes, { prefix: "/api" });
|
|
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
|
await app.register(workflowRoutes, { prefix: "/api" });
|
|
await app.register(leaveRoutes, { prefix: "/api" });
|
|
await app.register(delegationRoutes, { prefix: "/api" });
|
|
await app.register(auditRoutes, { prefix: "/api" });
|
|
await app.register(usersRoutes, { prefix: "/api" });
|
|
await app.register(employeesRoutes, { prefix: "/api" });
|
|
await app.register(departmentsRoutes, { prefix: "/api" });
|
|
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
|
await app.register(configReadRoutes, { prefix: "/api" });
|
|
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
|
await app.register(notificationsRoutes, { prefix: "/api" });
|
|
await app.register(exportsRoutes, { prefix: "/api" });
|
|
|
|
await startJobs(app);
|
|
|
|
return app;
|
|
}
|
|
|
|
module.exports = { buildApp };
|