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
这个提交包含在:
2026-02-07 16:43:31 +08:00
当前提交 f527633716
共修改 44 个文件,包含 11100 行新增和 0 行删除
+645
查看文件
@@ -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;