feat: 完成P10生产硬化与矩阵收口(P10E1-P10G1)

这个提交包含在:
2026-02-10 11:25:18 +08:00
父节点 1b6e801b12
当前提交 bec21e1b38
共修改 15 个文件,包含 1718 行新增和 793 行删除
+413
查看文件
@@ -0,0 +1,413 @@
const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildAuditLogData } = require("../../utils/audit");
const { signPayload, verifyToken } = require("../../utils/signedUrl");
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
const {
mapInvoiceSummary,
normalizeInvoiceResponse,
} = require("./shared/mappers");
const {
buildInvoiceDetailSelect,
buildInvoiceSummarySelect,
} = require("./shared/selects");
const invoiceListQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
ownerScope: z.enum(["own", "all"]).optional(),
});
function sanitizeFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
function parseInvoiceListQuery(query) {
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status,
ownerScope: parsed.data.ownerScope,
};
}
function ensureInvoiceReadableByUser(request, invoice) {
const roleCodes = request.employee.roleCodes;
if (
roleCodes.includes("admin") ||
roleCodes.includes("finance") ||
roleCodes.includes("finance_mgr")
) {
return true;
}
if (
invoice.owner_employee_id === request.employee.id ||
invoice.uploaded_by_employee_id === request.employee.id
) {
return true;
}
if (invoice.linked_reimbursement) {
const reimbursement = invoice.linked_reimbursement;
if (
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id
) {
return true;
}
}
return false;
}
async function hasPendingTaskAccess(fastify, request, linkedReimbursementId) {
if (!linkedReimbursementId) {
return false;
}
const pendingTask = await fastify.prisma.workflowTask.findFirst({
where: {
status: "pending",
workflow_instance: {
entity_type: "reimbursement",
entity_id: linkedReimbursementId,
current_status: "pending",
},
OR: [
{ assignee_id: request.employee.id },
{ assignee_role: { in: request.employee.roleCodes } },
],
},
select: { id: true },
});
return Boolean(pendingTask);
}
function registerInvoicePoolReadRoutes(fastify) {
fastify.get(
"/invoice-pool",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseInvoiceListQuery(request.query);
const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own");
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
if (!canViewOwn && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const ownerScope = query.ownerScope ?? (canManage ? "all" : "own");
if (ownerScope === "all" && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(ownerScope === "all"
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ uploaded_by_employee_id: request.employee.id },
{
linked_reimbursement: {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
],
},
},
],
}),
};
const [items, total] = await Promise.all([
fastify.prisma.invoicePool.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: buildInvoiceSummarySelect(),
}),
fastify.prisma.invoicePool.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
ownerScope,
items: items.map(mapInvoiceSummary),
});
},
);
fastify.get(
"/invoice-pool/:id",
{
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 invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: buildInvoiceDetailSelect({ includeLinkedReimbursement: true }),
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess) {
canAccess = await hasPendingTaskAccess(fastify, request, invoice.linked_reimbursement_id);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
fastify.post(
"/invoice-pool/:id/signed-url",
{
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 invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
linked_reimbursement_id: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess) {
canAccess = await hasPendingTaskAccess(fastify, request, invoice.linked_reimbursement_id);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
const token = signPayload(
{
invoiceId: invoice.id,
actorId: request.employee.id,
exp: expiresAt,
},
fastify.config.jwtSecret,
);
const origin =
fastify.config.appOrigin ||
`${request.protocol}://${request.headers.host ?? "localhost:3000"}`;
const signedUrl = `${origin}/api/invoice-pool/${invoice.id}/file?token=${encodeURIComponent(token)}`;
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_SIGNED_URL_ISSUED",
targetType: "invoice_pool",
targetId: invoice.id,
extra: {
invoiceId: invoice.id,
expiresAt,
},
}),
});
return sendData(reply, request, {
signedUrl,
expiresAt: new Date(expiresAt).toISOString(),
});
},
);
fastify.get("/invoice-pool/:id/file", async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
const token = request.query?.token;
if (!parsedInvoiceId.success || typeof token !== "string") {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const payload = verified.payload;
const parsedActorId = z.string().uuid().safeParse(payload?.actorId);
if (
payload?.invoiceId !== parsedInvoiceId.data ||
typeof payload?.exp !== "number" ||
payload.exp <= Date.now() ||
!parsedActorId.success
) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const actorId = parsedActorId.data;
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
storage_path: true,
mime_type: true,
original_name: true,
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let stream = null;
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: invoice.storage_path,
});
stream = opened.stream;
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
throw new AppError({
code: "SYS_001",
message: "服务器内部错误",
statusCode: 500,
details: {},
});
}
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "INVOICE_FILE_DOWNLOADED",
targetType: "invoice_pool",
targetId: parsedInvoiceId.data,
extra: {
invoiceId: parsedInvoiceId.data,
exp: payload.exp,
},
}),
});
} catch (error) {
fastify.log.error({ err: error, invoiceId: parsedInvoiceId.data }, "Audit log write failed");
}
reply.type(invoice.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
);
return reply.send(stream);
});
}
module.exports = {
registerInvoicePoolReadRoutes,
};