398 行
14 KiB
JavaScript
398 行
14 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 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 { startJobs } = require("./jobs/jobSystem");
|
|
const { initMetrics } = require("./observability");
|
|
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);
|
|
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(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 };
|