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"); 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", message: "参数校验失败", statusCode: 409, details: { verification: "disabled", featureFlag: reason }, }); } async function resolveVerificationDepartmentContext(tx, request, invoiceId) { const invoice = await tx.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, status: true, invoice_code: true, invoice_number: true, total_amount: true, remarks: true, owner_employee: { select: { id: true, department_id: true, }, }, linked_reimbursement: { select: { id: true, department_id: true, owner_id: true, submitter_id: true, }, }, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const departmentId = invoice.linked_reimbursement?.department_id ?? invoice.owner_employee?.department_id ?? request.employee.department_id ?? null; return { invoice, departmentId, }; } async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) { const context = await resolveVerificationDepartmentContext(tx, request, invoiceId); const evaluation = await evaluateVerificationAccess({ prisma: tx, config: fastify.config, departmentId: context.departmentId, strictDepartment: true, }); if (!evaluation.enabled) { throwVerificationDisabled(evaluation.reason); } return { ...context, evaluation, }; } function registerInvoiceVerificationRoutes(fastify) { fastify.post( "/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); if (!parsedInvoiceId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const invoiceId = parsedInvoiceId.data; const provider = getProvider(fastify.config); const now = Date.now(); const response = await fastify.prisma.$transaction(async (tx) => { const lockedRows = await tx.$queryRaw` SELECT id FROM "invoice_pool" WHERE id = ${invoiceId}::uuid FOR UPDATE `; if (!Array.isArray(lockedRows) || lockedRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId, }); const latest = await tx.invoiceVerificationRecord.findFirst({ where: { invoice_id: invoiceId }, orderBy: { created_at: "desc" }, select: { id: true, status: true, requested_at: true, attempts: true, }, }); if (latest?.status === "pending") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { verification: "already_pending" }, }); } if (latest?.requested_at) { const diffMs = now - new Date(latest.requested_at).getTime(); const cooldownMs = Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000; if (diffMs < cooldownMs) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { verification: "too_frequent" }, }); } } const created = await tx.invoiceVerificationRecord.create({ data: { invoice_id: invoiceId, provider, status: "pending", request_payload: { invoiceCode: invoice.invoice_code ?? null, invoiceNumber: invoice.invoice_number ?? null, totalAmount: invoice.total_amount?.toString?.() ?? null, departmentId, featureFlagReason: evaluation.reason, }, requested_by: request.employee.id, requested_at: new Date(now), attempts: 1, }, select: { id: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_VERIFICATION_REQUESTED", targetType: "invoice_pool", targetId: invoiceId, extra: { verificationId: created.id, provider, departmentId, featureFlagReason: evaluation.reason, }, }), }); return { verificationId: created.id, provider, departmentId, featureFlagReason: evaluation.reason, }; }); const trigger = await triggerVerificationJob(fastify, response.verificationId); if (trigger.mode === "inline") { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_VERIFICATION_INLINE_EXECUTED", targetType: "invoice_pool", targetId: invoiceId, extra: { verificationId: response.verificationId, status: trigger.result?.status ?? null, departmentId: response.departmentId, featureFlagReason: response.featureFlagReason, }, }), }); } return sendData(reply, request, { invoiceId, verificationId: response.verificationId, provider: response.provider, triggerMode: trigger.mode, }); }, ); fastify.get( "/invoice-pool/:id/verification", { preHandler: fastify.authenticate, }, async (request, reply) => { const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); if (!parsedInvoiceId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const query = parseVerificationListQuery(request.query); const invoiceId = parsedInvoiceId.data; const canManage = fastify.hasPermission(request.employee, "invoice.manage"); const canVerify = fastify.hasPermission(request.employee, "invoice.verify"); if (!canManage && !canVerify) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const invoice = await fastify.prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { id: true, owner_employee_id: true, uploaded_by_employee_id: true, linked_reimbursement: { select: { owner_id: true, submitter_id: true, }, }, }, }); if (!invoice) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const readable = canManage || canVerify || invoice.owner_employee_id === request.employee.id || invoice.uploaded_by_employee_id === request.employee.id || invoice.linked_reimbursement?.owner_id === request.employee.id || invoice.linked_reimbursement?.submitter_id === request.employee.id; if (!readable) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const items = await fastify.prisma.invoiceVerificationRecord.findMany({ where: { invoice_id: invoiceId }, orderBy: { created_at: "desc" }, take: query.limit, }); return sendData(reply, request, { invoiceId, items: items.map(mapVerificationRecord), }); }, ); fastify.post( "/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); if (!parsedInvoiceId.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } const invoiceId = parsedInvoiceId.data; const provider = getProvider(fastify.config); const created = await fastify.prisma.$transaction(async (tx) => { const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId, }); const latest = await tx.invoiceVerificationRecord.findFirst({ where: { invoice_id: invoiceId }, orderBy: { created_at: "desc" }, select: { id: true, status: true, attempts: true, requested_at: true, }, }); if (!latest) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { verification: "not_found" }, }); } if (latest.status === "pending") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { verification: "already_pending" }, }); } const cooldownMs = Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000; const diffMs = Date.now() - new Date(latest.requested_at).getTime(); if (diffMs < cooldownMs) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { verification: "too_frequent" }, }); } const attempts = Number(latest.attempts ?? 1) + 1; if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { verification: "attempts_exceeded" }, }); } const createdRecord = await tx.invoiceVerificationRecord.create({ data: { invoice_id: invoiceId, provider, status: "pending", retry_of_id: latest.id, attempts, requested_by: request.employee.id, requested_at: new Date(), request_payload: { retryOfId: latest.id, departmentId, featureFlagReason: evaluation.reason, }, }, select: { id: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "INVOICE_VERIFICATION_RETRIED", targetType: "invoice_pool", targetId: invoiceId, extra: { verificationId: createdRecord.id, retryOfId: latest.id, attempts, departmentId, featureFlagReason: evaluation.reason, }, }), }); return createdRecord; }); await triggerVerificationJob(fastify, created.id); return sendData(reply, request, { invoiceId, verificationId: created.id, status: "pending", }); }, ); } module.exports = { registerInvoiceVerificationRoutes, };