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 swaggerDocsPlugin = require("./plugins/swagger"); const authRoutes = require("./routes/auth"); const wecomAuthRoutes = require("./routes/wecomAuth"); 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 leaveTypesRoutes = require("./routes/leave-types"); const delegationRoutes = require("./routes/delegations"); const auditRoutes = require("./routes/audit"); const usersRoutes = require("./routes/users"); const departmentsRoutes = require("./routes/departments"); const masterDataRoutes = require("./routes/masterData"); const masterDataHolidayCalendarsRoutes = require("./routes/masterDataHolidayCalendars"); const masterDataSuppliersRoutes = require("./routes/masterDataSuppliers"); const employeesRoutes = require("./routes/employees"); const performanceRoutes = require("./routes/performance"); const payrollRoutes = require("./routes/payroll"); const expenseTypesRoutes = require("./routes/expenseTypes"); const expenseStandardsRoutes = require("./routes/expenseStandards"); const reimbursementTemplatesRoutes = require("./routes/reimbursementTemplates"); const configReadRoutes = require("./routes/configRead"); const approvalRulesRoutes = require("./routes/approvalRules"); const featureFlagsRoutes = require("./routes/featureFlags"); const notificationsRoutes = require("./routes/notifications"); const exportsRoutes = require("./routes/exports"); const dashboardRoutes = require("./routes/dashboard"); const budgetsRoutes = require("./routes/budgets"); const budgetControlRulesRoutes = require("./routes/budgetControlRules"); const feeControlRoutes = require("./routes/feeControl"); const riskFlagsRoutes = require("./routes/riskFlags"); const paymentBatchesRoutes = require("./routes/paymentBatches"); const apOperationsRoutes = require("./routes/apOperations"); const portalRoutes = require("./routes/portal"); const searchRoutes = require("./routes/search"); const announcementsRoutes = require("./routes/announcements"); const workflowDefinitionsRoutes = require("./routes/workflowDefinitions"); const migrationRunsRoutes = require("./routes/migrationRuns"); const cutoverRoutes = require("./routes/cutover"); const systemWorkspaceRoutes = require("./routes/systemWorkspace"); const presenceRoutes = require("./routes/presence"); const attendanceRoutes = require("./routes/attendance"); const weeklyReportsRoutes = require("./routes/weeklyReports"); const todosRoutes = require("./routes/todos"); const projectsRoutes = require("./routes/projects"); const contractsRegistryRoutes = require("./routes/contractsRegistry"); const confidentialDocRefsRoutes = require("./routes/confidentialDocRefs"); const purchaseRequestRoutes = require("./routes/purchaseRequests"); const assetsRoutes = require("./routes/assets"); const assetMovementsRoutes = require("./routes/assetMovements"); const inventoryChecksRoutes = require("./routes/inventoryChecks"); const integrationsRoutes = require("./routes/integrations"); const travelOrdersRoutes = require("./routes/travelOrders"); const travelRequestsRoutes = require("./routes/travelRequests"); const travelAuthorizationsRoutes = require("./routes/travelAuthorizations"); const travelTransactionsRoutes = require("./routes/travelTransactions"); const travelReconciliationRoutes = require("./routes/travelReconciliation"); const aiRoutes = require("./routes/ai"); const eventsRoutes = require("./routes/events"); const { startJobs } = require("./jobs/jobSystem"); const { initMetrics } = require("./observability"); const { createAppEventBus } = require("./services/events"); const { AppError } = require("./utils/AppError"); const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi"); const NO_CACHE_STATIC_FILES = new Set([ "index.html", "sw.js", "manifest.webmanifest", "registerSW.js", ]); function setStaticCacheHeaders(res, filePath) { const normalizedPath = String(filePath).replace(/\\/g, "/"); const fileName = normalizedPath.split("/").pop() ?? ""; if (NO_CACHE_STATIC_FILES.has(fileName)) { res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); return; } if (normalizedPath.includes("/assets/") || /\/workbox-[^/]+\.js$/i.test(normalizedPath)) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } } 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); app.decorate("appEvents", createAppEventBus()); 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, { cacheControl: false, root: config.clientDistDir, setHeaders: setStaticCacheHeaders, }); 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" ) { reply.header("Cache-Control", "no-cache, no-store, must-revalidate"); 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); if (config.nodeEnv !== "production" && config.openapiDocsEnabled) { await app.register(swaggerDocsPlugin); app.log.info("OpenAPI docs enabled at /api/docs"); } if (config.otelTracesEnabled) { app.log.info("OTel tracing enabled"); } app.get( "/health", { schema: { summary: "Health check", tags: ["system"], response: { 200: buildDataEnvelopeSchema({ type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, }, }), }, }, }, async (request, reply) => { return reply.send({ requestId: request.id, data: { ok: true } }); }, ); app.get( "/ready", { schema: { summary: "Readiness check", tags: ["system"], response: { 200: buildDataEnvelopeSchema({ type: "object", required: ["ok"], properties: { ok: { type: "boolean" }, }, }), 503: buildErrorEnvelopeSchema({ description: "数据库不可用", codeExample: "SYS_503", messageExample: "Service Unavailable", detailsExample: { db: "unreachable" }, }), }, }, }, 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(wecomAuthRoutes, { prefix: "/api/auth" }); await app.register(eventsRoutes, { prefix: "/api" }); 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(leaveTypesRoutes, { 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(performanceRoutes, { prefix: "/api" }); await app.register(payrollRoutes, { prefix: "/api" }); await app.register(departmentsRoutes, { prefix: "/api" }); await app.register(masterDataRoutes, { prefix: "/api" }); await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" }); await app.register(masterDataSuppliersRoutes, { prefix: "/api" }); await app.register(expenseTypesRoutes, { prefix: "/api" }); await app.register(expenseStandardsRoutes, { prefix: "/api" }); await app.register(reimbursementTemplatesRoutes, { prefix: "/api" }); await app.register(configReadRoutes, { prefix: "/api" }); await app.register(approvalRulesRoutes, { prefix: "/api" }); await app.register(featureFlagsRoutes, { prefix: "/api" }); await app.register(notificationsRoutes, { prefix: "/api" }); await app.register(dashboardRoutes, { prefix: "/api" }); await app.register(budgetsRoutes, { prefix: "/api" }); await app.register(budgetControlRulesRoutes, { prefix: "/api" }); await app.register(feeControlRoutes, { prefix: "/api" }); await app.register(riskFlagsRoutes, { prefix: "/api" }); await app.register(paymentBatchesRoutes, { prefix: "/api" }); await app.register(apOperationsRoutes, { prefix: "/api" }); await app.register(portalRoutes, { prefix: "/api" }); await app.register(searchRoutes, { prefix: "/api" }); await app.register(announcementsRoutes, { prefix: "/api" }); await app.register(workflowDefinitionsRoutes, { prefix: "/api" }); await app.register(migrationRunsRoutes, { prefix: "/api" }); await app.register(cutoverRoutes, { prefix: "/api" }); await app.register(systemWorkspaceRoutes, { prefix: "/api" }); await app.register(presenceRoutes, { prefix: "/api" }); await app.register(attendanceRoutes, { prefix: "/api" }); await app.register(weeklyReportsRoutes, { prefix: "/api" }); await app.register(todosRoutes, { prefix: "/api" }); await app.register(projectsRoutes, { prefix: "/api" }); await app.register(contractsRegistryRoutes, { prefix: "/api" }); await app.register(confidentialDocRefsRoutes, { prefix: "/api" }); await app.register(purchaseRequestRoutes, { prefix: "/api" }); await app.register(assetsRoutes, { prefix: "/api" }); await app.register(assetMovementsRoutes, { prefix: "/api" }); await app.register(inventoryChecksRoutes, { prefix: "/api" }); await app.register(integrationsRoutes, { prefix: "/api" }); await app.register(travelOrdersRoutes, { prefix: "/api" }); await app.register(travelRequestsRoutes, { prefix: "/api" }); await app.register(travelAuthorizationsRoutes, { prefix: "/api" }); await app.register(travelTransactionsRoutes, { prefix: "/api" }); await app.register(travelReconciliationRoutes, { prefix: "/api" }); await app.register(aiRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" }); await startJobs(app); return app; } module.exports = { buildApp };