feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
@@ -0,0 +1,384 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { getProvider, isVerificationEnabled } = require("../../services/verification");
|
||||
|
||||
const { mapVerificationRecord } = require("./shared/mappers");
|
||||
const { parseVerificationListQuery } = require("./shared/schemas");
|
||||
const { triggerVerificationJob } = require("./shared/helpers");
|
||||
|
||||
function registerInvoiceVerificationRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/invoice-pool/:id/verify",
|
||||
{
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
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 = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
},
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
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"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
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 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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return createdRecord;
|
||||
});
|
||||
|
||||
await triggerVerificationJob(fastify, created.id);
|
||||
|
||||
return sendData(reply, request, {
|
||||
invoiceId,
|
||||
verificationId: created.id,
|
||||
status: "pending",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerInvoiceVerificationRoutes,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户