文件
hyb-oa/server/src/app.js
T
wangsli 6ecebf8ad6 feat(server): /metrics 可观测与日志上下文(F22)
- 新增 Prometheus 指标端点 /metrics(HTTP + DB + 作业统计)
- request.log 绑定 requestId/actorId,避免日志难追踪
- cron/pg-boss 作业增加 metrics 计数(runs/processed)
- 更新 env/runbook/README 与 v3 CSV 状态推进
2026-02-09 20:30:41 +08:00

235 行
7.0 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 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(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 };