feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)

这个提交包含在:
2026-02-13 00:25:04 +08:00
父节点 3b221a2202
当前提交 1412fead5d
共修改 30 个文件,包含 3988 行新增和 105 行删除
+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);
+1
查看文件
@@ -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),
+37
查看文件
@@ -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" });
+130
查看文件
@@ -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);
+63 -1
查看文件
@@ -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);
+89
查看文件
@@ -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,
};