feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)
这个提交包含在:
+54
-5
@@ -14,6 +14,7 @@ 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 meRoutes = require("./routes/me");
|
||||
const reimbursementRoutes = require("./routes/reimbursements");
|
||||
@@ -38,6 +39,7 @@ const travelOrdersRoutes = require("./routes/travelOrders");
|
||||
const { startJobs } = require("./jobs/jobSystem");
|
||||
const { initMetrics } = require("./observability");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
|
||||
|
||||
async function buildApp() {
|
||||
const config = getConfig();
|
||||
@@ -180,11 +182,57 @@ async function buildApp() {
|
||||
await app.register(prismaPlugin);
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.get("/health", async (request, reply) => {
|
||||
return reply.send({ requestId: request.id, data: { ok: true } });
|
||||
});
|
||||
if (config.nodeEnv !== "production" && config.openapiDocsEnabled) {
|
||||
await app.register(swaggerDocsPlugin);
|
||||
app.log.info("OpenAPI docs enabled at /api/docs");
|
||||
}
|
||||
|
||||
app.get("/ready", async (request, reply) => {
|
||||
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 } });
|
||||
@@ -197,7 +245,8 @@ async function buildApp() {
|
||||
details: { db: "unreachable" },
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (config.metricsEnabled) {
|
||||
initMetrics(app);
|
||||
|
||||
@@ -66,6 +66,7 @@ function getConfig() {
|
||||
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
serveClientDist: process.env.SERVE_CLIENT_DIST === "true",
|
||||
openapiDocsEnabled: process.env.OPENAPI_DOCS_ENABLED === "true",
|
||||
clientDistDir: path.resolve(serverRoot, clientDistDir),
|
||||
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
||||
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const fp = require("fastify-plugin");
|
||||
const fastifySwagger = require("@fastify/swagger");
|
||||
const fastifySwaggerUi = require("@fastify/swagger-ui");
|
||||
const pkg = require("../../package.json");
|
||||
|
||||
async function swaggerPlugin(fastify) {
|
||||
await fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "hyb-oa API",
|
||||
description: "hyb-oa 后端接口文档(治理链路优先)",
|
||||
version: pkg.version,
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.register(fastifySwaggerUi, {
|
||||
routePrefix: "/api/docs",
|
||||
staticCSP: true,
|
||||
uiConfig: {
|
||||
docExpansion: "list",
|
||||
deepLinking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = fp(swaggerPlugin, { name: "swagger-docs" });
|
||||
@@ -3,6 +3,10 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const {
|
||||
buildDataEnvelopeSchema,
|
||||
buildErrorEnvelopeSchema,
|
||||
} = require("../utils/openapi");
|
||||
const {
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
@@ -24,6 +28,49 @@ const UPDATE_BODY_SCHEMA = z.object({
|
||||
expectedVersion: z.coerce.number().int().min(1),
|
||||
});
|
||||
|
||||
const FEATURE_FLAG_ITEM_SCHEMA = {
|
||||
type: "object",
|
||||
required: [
|
||||
"id",
|
||||
"flagKey",
|
||||
"overrideMode",
|
||||
"rolloutType",
|
||||
"departmentIds",
|
||||
"note",
|
||||
"version",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"updatedAt",
|
||||
"updatedBy",
|
||||
"envEnabled",
|
||||
"effectiveEnabled",
|
||||
"effectiveReason",
|
||||
"matchedDepartmentId",
|
||||
"requiresDepartment",
|
||||
],
|
||||
properties: {
|
||||
id: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
flagKey: { type: "string", enum: FEATURE_FLAG_KEY_LIST },
|
||||
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
|
||||
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
|
||||
departmentIds: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "uuid" },
|
||||
},
|
||||
note: { type: "string" },
|
||||
version: { type: "integer" },
|
||||
createdAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
|
||||
createdBy: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
updatedAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
|
||||
updatedBy: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
envEnabled: { type: "boolean" },
|
||||
effectiveEnabled: { type: "boolean" },
|
||||
effectiveReason: { type: "string" },
|
||||
matchedDepartmentId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
requiresDepartment: { type: "boolean" },
|
||||
},
|
||||
};
|
||||
|
||||
function parseKey(params) {
|
||||
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -66,6 +113,31 @@ async function featureFlagsRoutes(fastify) {
|
||||
"/system/feature-flags",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
schema: {
|
||||
tags: ["system", "feature-flags"],
|
||||
summary: "获取功能开关列表",
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(
|
||||
{
|
||||
type: "object",
|
||||
required: ["items"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: FEATURE_FLAG_ITEM_SCHEMA,
|
||||
},
|
||||
},
|
||||
},
|
||||
"功能开关列表",
|
||||
),
|
||||
403: buildErrorEnvelopeSchema({
|
||||
description: "无权限",
|
||||
codeExample: "PERM_001",
|
||||
messageExample: "无权限",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const items = await listFeatureFlags(fastify.prisma, fastify.config);
|
||||
@@ -77,6 +149,64 @@ async function featureFlagsRoutes(fastify) {
|
||||
"/system/feature-flags/:key",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
schema: {
|
||||
tags: ["system", "feature-flags"],
|
||||
summary: "更新功能开关",
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["key"],
|
||||
properties: {
|
||||
key: { type: "string", enum: FEATURE_FLAG_KEY_LIST },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["overrideMode", "rolloutType", "departmentIds", "expectedVersion"],
|
||||
properties: {
|
||||
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
|
||||
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
|
||||
departmentIds: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "uuid" },
|
||||
},
|
||||
note: { type: "string", maxLength: 500 },
|
||||
expectedVersion: { type: "integer", minimum: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(
|
||||
{
|
||||
type: "object",
|
||||
required: ["item"],
|
||||
properties: {
|
||||
item: FEATURE_FLAG_ITEM_SCHEMA,
|
||||
},
|
||||
},
|
||||
"更新后的功能开关",
|
||||
),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: "参数校验失败",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: "开关不存在",
|
||||
codeExample: "SYS_404",
|
||||
messageExample: "Not Found",
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: "版本冲突",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
detailsExample: {
|
||||
featureFlag: "version_conflict",
|
||||
expectedVersion: 2,
|
||||
currentVersion: 3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const key = parseKey(request.params);
|
||||
|
||||
@@ -3,16 +3,74 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const {
|
||||
buildDataEnvelopeSchema,
|
||||
buildErrorEnvelopeSchema,
|
||||
} = require("../utils/openapi");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
|
||||
|
||||
const ERP_SYNC_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["outboxId", "status", "duplicated"],
|
||||
properties: {
|
||||
outboxId: { type: "string", format: "uuid" },
|
||||
status: { type: "string" },
|
||||
duplicated: { type: "boolean" },
|
||||
},
|
||||
};
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/integrations/erp/sync/:reimbursementId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("integration.manage"),
|
||||
schema: {
|
||||
tags: ["integrations"],
|
||||
summary: "手动触发 ERP 同步",
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["reimbursementId"],
|
||||
properties: {
|
||||
reimbursementId: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
runNow: {
|
||||
anyOf: [{ type: "string", enum: ["1", "true"] }, { type: "boolean" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, "ERP 同步任务已入队"),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: "参数校验失败",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: "报销单不存在",
|
||||
codeExample: "SYS_404",
|
||||
messageExample: "Not Found",
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: "状态不满足或开关阻断",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
detailsExample: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: "erp_sync",
|
||||
reason: "department_not_allowed",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
|
||||
@@ -106,7 +164,11 @@ async function integrationsRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
const runNow = request.query?.runNow === "1" || request.query?.runNow === 1 || request.query?.runNow === true || request.query?.runNow === "true";
|
||||
const runNow =
|
||||
request.query?.runNow === "1" ||
|
||||
request.query?.runNow === 1 ||
|
||||
request.query?.runNow === true ||
|
||||
request.query?.runNow === "true";
|
||||
if (runNow) {
|
||||
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema, UUID_PARAM_SCHEMA } = require("../../utils/openapi");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
|
||||
|
||||
@@ -9,6 +10,27 @@ const { mapVerificationRecord } = require("./shared/mappers");
|
||||
const { parseVerificationListQuery } = require("./shared/schemas");
|
||||
const { triggerVerificationJob } = require("./shared/helpers");
|
||||
|
||||
const VERIFY_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["invoiceId", "verificationId", "provider", "triggerMode"],
|
||||
properties: {
|
||||
invoiceId: { type: "string", format: "uuid" },
|
||||
verificationId: { type: "string", format: "uuid" },
|
||||
provider: { type: "string" },
|
||||
triggerMode: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
const RETRY_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["invoiceId", "verificationId", "status"],
|
||||
properties: {
|
||||
invoiceId: { type: "string", format: "uuid" },
|
||||
verificationId: { type: "string", format: "uuid" },
|
||||
status: { type: "string", enum: ["pending"] },
|
||||
},
|
||||
};
|
||||
|
||||
function throwVerificationDisabled(reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
@@ -89,6 +111,31 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
"/invoice-pool/:id/verify",
|
||||
{
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
schema: {
|
||||
tags: ["invoice-verification"],
|
||||
summary: "触发发票验真",
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: UUID_PARAM_SCHEMA,
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(VERIFY_RESPONSE_SCHEMA, "验真任务已创建"),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: "参数校验失败",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: "票据不存在",
|
||||
codeExample: "SYS_404",
|
||||
messageExample: "Not Found",
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: "触发受限(开关/频控/并发)",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
detailsExample: { verification: "disabled", featureFlag: "env_hard_off" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
@@ -319,6 +366,31 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
"/invoice-pool/:id/verification/retry",
|
||||
{
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
schema: {
|
||||
tags: ["invoice-verification"],
|
||||
summary: "重试发票验真",
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: UUID_PARAM_SCHEMA,
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(RETRY_RESPONSE_SCHEMA, "验真重试任务已创建"),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: "参数校验失败",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: "票据不存在",
|
||||
codeExample: "SYS_404",
|
||||
messageExample: "Not Found",
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: "重试受限(开关/频控/重试次数)",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
detailsExample: { verification: "too_frequent" },
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
const REQUEST_ID_SCHEMA = {
|
||||
type: "string",
|
||||
description: "服务端生成的请求追踪ID",
|
||||
};
|
||||
|
||||
const ERROR_DETAILS_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const ERROR_BODY_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["code", "message", "details"],
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
message: { type: "string" },
|
||||
details: ERROR_DETAILS_SCHEMA,
|
||||
},
|
||||
};
|
||||
|
||||
function buildDataEnvelopeSchema(dataSchema, description = "OK") {
|
||||
return {
|
||||
description,
|
||||
type: "object",
|
||||
required: ["requestId", "data"],
|
||||
properties: {
|
||||
requestId: REQUEST_ID_SCHEMA,
|
||||
data: dataSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildErrorEnvelopeSchema({
|
||||
description = "业务错误",
|
||||
codeExample = "VALID_001",
|
||||
messageExample = "参数校验失败",
|
||||
detailsExample,
|
||||
} = {}) {
|
||||
const schema = {
|
||||
description,
|
||||
type: "object",
|
||||
required: ["requestId", "error"],
|
||||
properties: {
|
||||
requestId: REQUEST_ID_SCHEMA,
|
||||
error: {
|
||||
...ERROR_BODY_SCHEMA,
|
||||
properties: {
|
||||
...ERROR_BODY_SCHEMA.properties,
|
||||
code: {
|
||||
...ERROR_BODY_SCHEMA.properties.code,
|
||||
example: codeExample,
|
||||
},
|
||||
message: {
|
||||
...ERROR_BODY_SCHEMA.properties.message,
|
||||
example: messageExample,
|
||||
},
|
||||
details: {
|
||||
...ERROR_DETAILS_SCHEMA,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (detailsExample !== undefined) {
|
||||
schema.properties.error.properties.details.example = detailsExample;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
const UUID_PARAM_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
format: "uuid",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
REQUEST_ID_SCHEMA,
|
||||
ERROR_BODY_SCHEMA,
|
||||
buildDataEnvelopeSchema,
|
||||
buildErrorEnvelopeSchema,
|
||||
UUID_PARAM_SCHEMA,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户