feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)
P0 MVP: - 认证:login/refresh/logout/forgot/reset + tokenVersion - RBAC:角色到权限映射 + requirePermission - 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005) - 报销:create/view/submit + 单号生成 - 工作流:approve + department_manager 解析 P1 扩展: - 动作:reject/return/withdraw(含 comment) - transfer:任务转审 + 链路上限 3 - pay:approved -> paid + 发票归档 used_complete - 动态审批链:approval_rules 驱动 回归脚本: - smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
+121
@@ -0,0 +1,121 @@
|
||||
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 { 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 { AppError } = require("./utils/AppError");
|
||||
|
||||
async function buildApp() {
|
||||
const config = getConfig();
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
reply.header("x-request-id", request.id);
|
||||
});
|
||||
|
||||
app.setNotFoundHandler(async () => {
|
||||
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 } });
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { buildApp };
|
||||
在新工单中引用
屏蔽一个用户