feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)
这个提交包含在:
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户