文件
hyb-oa/server/src/routes/invoicePool/verification.js
T

521 行
15 KiB
JavaScript

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,
};