feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)
P0 MVP: - 认证:login/refresh/logout/forgot/reset + tokenVersion - RBAC:角色到权限映射 + requirePermission - 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005) - 报销:create/view/submit + 单号生成 - 工作流:approve + department_manager 解析 P1 扩展: - 动作:reject/return/withdraw(含 comment) - transfer:任务转审 + 链路上限 3 - pay:approved -> paid + 发票归档 used_complete - 动态审批链:approval_rules 驱动 回归脚本: - smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
@@ -0,0 +1,645 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs/promises");
|
||||
const fsSync = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { compactDateString } = require("../utils/date");
|
||||
const { sha256Hex } = require("../utils/crypto");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { toMoneyString } = require("../utils/workflow");
|
||||
|
||||
const MIME_ALLOWLIST = new Set([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
|
||||
|
||||
const bindBodySchema = z.object({
|
||||
reimbursementId: z.string().uuid(),
|
||||
linkedItemId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const DEDUP_CONSTRAINT_NAMES = new Set([
|
||||
"invoice_pool_file_sha256_dedup_ux",
|
||||
"invoice_pool_invoice_code_number_dedup_ux",
|
||||
]);
|
||||
|
||||
const DEDUP_FIELD_NAMES = new Set(["file_sha256", "invoice_code", "invoice_number"]);
|
||||
|
||||
function tokenizeMetaString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const splitTokens = normalized
|
||||
.split(/[^a-z0-9_]+/i)
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return [normalized, ...splitTokens];
|
||||
}
|
||||
|
||||
function normalizeConstraintMeta(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.flatMap((item) => tokenizeMetaString(String(item)))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return tokenizeMetaString(value);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectFieldTokens(meta) {
|
||||
return new Set(
|
||||
[
|
||||
...normalizeConstraintMeta(meta.target),
|
||||
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.constraint?.fields),
|
||||
].filter((token) => DEDUP_FIELD_NAMES.has(token)),
|
||||
);
|
||||
}
|
||||
|
||||
function containsDedupConstraintName(metaTokens) {
|
||||
const constraints = Array.from(DEDUP_CONSTRAINT_NAMES);
|
||||
return metaTokens.some((token) => constraints.some((constraint) => token.includes(constraint)));
|
||||
}
|
||||
|
||||
function isFieldBasedDedup(fieldTokens) {
|
||||
if (fieldTokens.size === 1 && fieldTokens.has("file_sha256")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
fieldTokens.size === 2 &&
|
||||
fieldTokens.has("invoice_code") &&
|
||||
fieldTokens.has("invoice_number")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInvoiceDedupConflict(error) {
|
||||
if (!error || error.code !== "P2002") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = error.meta ?? {};
|
||||
const metaTokens = [
|
||||
...normalizeConstraintMeta(meta.constraint),
|
||||
...normalizeConstraintMeta(meta.target),
|
||||
...normalizeConstraintMeta(meta?.driverAdapterError?.cause?.originalMessage),
|
||||
];
|
||||
|
||||
if (containsDedupConstraintName(metaTokens)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fieldTokens = collectFieldTokens(meta);
|
||||
return isFieldBasedDedup(fieldTokens);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function sanitizeFilename(fileName) {
|
||||
if (!fileName) return "file";
|
||||
return fileName.replace(/[\r\n"]/g, "_");
|
||||
}
|
||||
|
||||
async function invoicePoolRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/invoice-pool/upload",
|
||||
{
|
||||
preHandler: fastify.requirePermission("invoice.upload"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
let filePart = null;
|
||||
const fields = {};
|
||||
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "file") {
|
||||
if (filePart) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "only_single_file_supported" },
|
||||
});
|
||||
}
|
||||
filePart = part;
|
||||
} else {
|
||||
fields[part.fieldname] = part.value;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "file_too_large" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!filePart) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "missing" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!MIME_ALLOWLIST.has(filePart.mimetype)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "unsupported_mime_type" },
|
||||
});
|
||||
}
|
||||
|
||||
const ownerEmployeeId = fields.owner_employee_id ?? request.employee.id;
|
||||
if (ownerEmployeeId !== request.employee.id) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { owner_employee_id: "cross_owner_upload_not_allowed_in_mvp" },
|
||||
});
|
||||
}
|
||||
|
||||
const ocrType = fields.ocr_type ?? "general";
|
||||
if (!OCR_TYPE_ALLOWLIST.has(ocrType)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { ocr_type: "invalid" },
|
||||
});
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let fileSize = 0;
|
||||
for await (const chunk of filePart.file) {
|
||||
fileSize += chunk.length;
|
||||
if (fileSize > fastify.config.maxUploadSizeBytes) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "file_too_large" },
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if (fileSize === 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { file: "empty_file" },
|
||||
});
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
const fileSha256 = sha256Hex(fileBuffer);
|
||||
const datePrefix = compactDateString(fastify.config.appTimezone);
|
||||
const extension = path.extname(filePart.filename ?? "").toLowerCase();
|
||||
const storagePath = path.join(datePrefix, `${crypto.randomUUID()}${extension}`);
|
||||
const absoluteStoragePath = path.resolve(fastify.config.storageDir, storagePath);
|
||||
|
||||
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
|
||||
await fs.writeFile(absoluteStoragePath, fileBuffer);
|
||||
|
||||
const invoice = await fastify.prisma.invoicePool.create({
|
||||
data: {
|
||||
owner_employee_id: ownerEmployeeId,
|
||||
uploaded_by_employee_id: request.employee.id,
|
||||
storage_path: storagePath,
|
||||
original_name: filePart.filename ?? "upload",
|
||||
mime_type: filePart.mimetype,
|
||||
file_size: fileSize,
|
||||
file_sha256: fileSha256,
|
||||
ocr_type: ocrType,
|
||||
status: "available",
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
file_sha256: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
invoicePoolId: invoice.id,
|
||||
status: invoice.status,
|
||||
fileSha256: invoice.file_sha256,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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 && invoice.linked_reimbursement_id) {
|
||||
const pendingTask = await fastify.prisma.workflowTask.findFirst({
|
||||
where: {
|
||||
status: "pending",
|
||||
workflow_instance: {
|
||||
entity_type: "reimbursement",
|
||||
entity_id: invoice.linked_reimbursement_id,
|
||||
current_status: "pending",
|
||||
},
|
||||
OR: [
|
||||
{ assignee_id: request.employee.id },
|
||||
{ assignee_role: { in: request.employee.roleCodes } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
canAccess = Boolean(pendingTask);
|
||||
}
|
||||
|
||||
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,
|
||||
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)}`;
|
||||
|
||||
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;
|
||||
if (
|
||||
payload?.invoiceId !== parsedInvoiceId.data ||
|
||||
typeof payload?.exp !== "number" ||
|
||||
payload.exp <= Date.now()
|
||||
) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(fastify.config.storageDir);
|
||||
const absolutePath = path.resolve(storageRoot, invoice.storage_path);
|
||||
if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
} catch {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
reply.type(invoice.mime_type || "application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
`inline; filename=\"${sanitizeFilename(invoice.original_name)}\"`,
|
||||
);
|
||||
return reply.send(fsSync.createReadStream(absolutePath));
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
"/invoice-pool/:id/bind",
|
||||
{
|
||||
preHandler: fastify.requirePermission("invoice.upload"),
|
||||
},
|
||||
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 parsedBody = bindBodySchema.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceId = parsedInvoiceId.data;
|
||||
const { reimbursementId, linkedItemId } = parsedBody.data;
|
||||
|
||||
try {
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, owner_id, submitter_id, status
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
const isOwn =
|
||||
reimbursement.owner_id === request.employee.id ||
|
||||
reimbursement.submitter_id === request.employee.id;
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
if (!isOwn && !canViewAll) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (![
|
||||
"draft",
|
||||
"rejected",
|
||||
].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_bind" },
|
||||
});
|
||||
}
|
||||
|
||||
if (linkedItemId) {
|
||||
const linkedItem = await tx.reimbursementItem.findFirst({
|
||||
where: {
|
||||
id: linkedItemId,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!linkedItem) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { linkedItemId: "item_not_in_reimbursement" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceRows = await tx.$queryRaw`
|
||||
SELECT id
|
||||
FROM "invoice_pool"
|
||||
WHERE id = ${invoiceId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(invoiceRows) || invoiceRows.length === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
status: true,
|
||||
linked_reimbursement_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.status === "bound" || invoice.linked_reimbursement_id) {
|
||||
throw new AppError({
|
||||
code: "VALID_005",
|
||||
message: "发票已被其他报销单占用/绑定",
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.status !== "available") {
|
||||
throw new AppError({
|
||||
code: "VALID_005",
|
||||
message: "发票已被其他报销单占用/绑定",
|
||||
statusCode: 409,
|
||||
details: { status: invoice.status },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
status: "bound",
|
||||
linked_reimbursement_id: reimbursementId,
|
||||
linked_item_id: linkedItemId ?? null,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isInvoiceDedupConflict(error)) {
|
||||
throw new AppError({
|
||||
code: "VALID_003",
|
||||
message: "发票判重命中",
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
if (error?.code === "P2002") {
|
||||
fastify.log.warn(
|
||||
{ meta: error?.meta ?? {} },
|
||||
"Unexpected P2002 not from invoice dedup constraints",
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invoice = await fastify.prisma.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
linked_reimbursement_id: true,
|
||||
linked_item_id: true,
|
||||
total_amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: invoice.id,
|
||||
status: invoice.status,
|
||||
linkedReimbursementId: invoice.linked_reimbursement_id,
|
||||
linkedItemId: invoice.linked_item_id,
|
||||
totalAmount: toMoneyString(invoice.total_amount),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = invoicePoolRoutes;
|
||||
在新工单中引用
屏蔽一个用户