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 };