This commit is contained in:
+142
-23
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { lstat, rm, stat, unlink } from "node:fs/promises";
|
||||
import { rm, stat, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
@@ -393,8 +393,8 @@ function filteredExpenses(database: DatabaseContext, config: AppConfig, query: z
|
||||
`).all(query.status, start, end, `%${escaped}%`) as ExpenseRow[];
|
||||
}
|
||||
|
||||
function enqueueFileDeletion(database: DatabaseContext, storagePath: string, reason: string): void {
|
||||
database.sqlite.prepare(`
|
||||
function enqueueFileDeletion(sqlite: DatabaseContext["sqlite"], storagePath: string, reason: string): void {
|
||||
sqlite.prepare(`
|
||||
INSERT INTO file_deletions(id, storage_path, reason, status, attempts, created_at)
|
||||
VALUES (?, ?, ?, 'pending', 0, ?)
|
||||
`).run(randomUUID(), storagePath, reason, Date.now());
|
||||
@@ -426,13 +426,14 @@ function clearReauthFailures(database: DatabaseContext, request: FastifyRequest,
|
||||
database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash=?").run(reauthKey(request, adminId));
|
||||
}
|
||||
|
||||
async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig): Promise<{ fields: Record<string, string>; files: StagedFile[] }> {
|
||||
const fields: Record<string, string> = {};
|
||||
const files: StagedFile[] = [];
|
||||
async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig, options: { allowVersion?: boolean } = {}): Promise<{ fields: Record<string, string>; files: StagedFile[] }> {
|
||||
const fields: Record<string, string> = {};
|
||||
const files: StagedFile[] = [];
|
||||
const allowedFields = new Set(["paidAt", "amount", "note", "invoiceMissingReason", ...(options.allowVersion ? ["version"] : [])]);
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
if (!["paidAt", "amount", "note", "invoiceMissingReason"].includes(part.fieldname)) {
|
||||
if (!allowedFields.has(part.fieldname)) {
|
||||
throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段");
|
||||
}
|
||||
if (part.fieldname in fields) {
|
||||
@@ -456,18 +457,127 @@ async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig)
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteAll(config: AppConfig, files: StagedFile[]): Promise<Array<StagedFile & { storagePath: string }>> {
|
||||
function promotedRelativePath(file: StagedFile): string {
|
||||
return path.join(file.id.slice(0, 2), `${file.id}.${file.extension}`);
|
||||
}
|
||||
|
||||
async function cleanupPromotedFiles(sqlite: DatabaseContext["sqlite"] | undefined, config: AppConfig, files: Array<StagedFile & { storagePath?: string }>): Promise<void> {
|
||||
await Promise.all(files.map(async (file) => {
|
||||
const relative = file.storagePath || promotedRelativePath(file);
|
||||
try {
|
||||
await unlink(safeStoragePath(config.filesDir, relative));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") return;
|
||||
if (sqlite) {
|
||||
try { enqueueFileDeletion(sqlite, relative, "attachment_rollback"); } catch { /* database may already be closing */ }
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function promoteAll(config: AppConfig, files: StagedFile[], sqlite?: DatabaseContext["sqlite"]): Promise<Array<StagedFile & { storagePath: string }>> {
|
||||
const promoted: Array<StagedFile & { storagePath: string }> = [];
|
||||
try {
|
||||
for (const file of files) promoted.push({ ...file, storagePath: await promoteStagedFile(config, file) });
|
||||
return promoted;
|
||||
} catch (error) {
|
||||
await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined)));
|
||||
// Include the file currently being promoted: rename() may have succeeded
|
||||
// before a directory sync/close error was raised.
|
||||
await cleanupPromotedFiles(sqlite, config, files);
|
||||
await discardStaged(files);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateExpenseMultipart(database: DatabaseContext, config: AppConfig, request: FastifyRequest, id: string) {
|
||||
const { fields, files } = await parseExpenseMultipart(request, config, { allowVersion: true });
|
||||
let input: z.infer<typeof expenseUpdateSchema>;
|
||||
try {
|
||||
input = expenseUpdateSchema.parse({
|
||||
paidAt: fields.paidAt,
|
||||
amount: fields.amount,
|
||||
note: fields.note ?? "",
|
||||
invoiceMissingReason: fields.invoiceMissingReason,
|
||||
version: fields.version === undefined ? undefined : Number(fields.version),
|
||||
});
|
||||
} catch (error) {
|
||||
await discardStaged(files);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const before = getExpense(database, id);
|
||||
if (!before) { await discardStaged(files); notFound("账目不存在"); }
|
||||
if (before.version !== input.version) { await discardStaged(files); conflict(database, id); }
|
||||
const paymentProofs = files.filter((file) => file.kind === "payment_proof");
|
||||
const invoiceFiles = files.filter((file) => file.kind === "invoice");
|
||||
// Adding an invoice supersedes the previous no-invoice explanation. This
|
||||
// mirrors the standalone attachment endpoint and keeps the two states
|
||||
// mutually exclusive even when a client omits the optional field.
|
||||
const requestedReason = invoiceFiles.length > 0
|
||||
? null
|
||||
: input.invoiceMissingReason === undefined
|
||||
? before.invoiceMissingReason
|
||||
: normalizeInvoiceMissingReason(input.invoiceMissingReason);
|
||||
const nextInvoiceCount = Number(before.invoiceCount) + invoiceFiles.length;
|
||||
const nextProofCount = Number(before.paymentProofCount) + paymentProofs.length;
|
||||
if (nextProofCount < 1) { await discardStaged(files); throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); }
|
||||
try { assertInvoiceCoverage(nextInvoiceCount, requestedReason); } catch (error) { await discardStaged(files); throw error; }
|
||||
const addedBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
|
||||
const currentBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total;
|
||||
if (currentBytes + addedBytes > config.maxRecordBytes) { await discardStaged(files); throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); }
|
||||
const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total;
|
||||
if (globalBytes + addedBytes > config.maxTotalBytes) { await discardStaged(files); throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); }
|
||||
const paidAt = Date.parse(input.paidAt);
|
||||
if (!Number.isFinite(paidAt)) { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); }
|
||||
let amountCents: number;
|
||||
try {
|
||||
amountCents = amountToCents(input.amount);
|
||||
} catch {
|
||||
await discardStaged(files);
|
||||
throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数");
|
||||
}
|
||||
const promoted = await promoteAll(config, files, database.sqlite);
|
||||
const now = Date.now();
|
||||
try {
|
||||
database.sqlite.transaction(() => {
|
||||
const current = getExpense(database, id);
|
||||
if (!current) notFound("账目不存在");
|
||||
if (current.version !== input.version) conflict(database, id);
|
||||
const invoiceCount = Number(current.invoiceCount) + invoiceFiles.length;
|
||||
const proofCount = Number(current.paymentProofCount) + paymentProofs.length;
|
||||
if (proofCount < 1) throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证");
|
||||
const invoiceMissingReason = invoiceFiles.length > 0 ? null : (input.invoiceMissingReason === undefined ? current.invoiceMissingReason : normalizeInvoiceMissingReason(input.invoiceMissingReason));
|
||||
assertInvoiceCoverage(invoiceCount, invoiceMissingReason);
|
||||
const liveBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total;
|
||||
if (liveBytes + addedBytes > config.maxRecordBytes) throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制");
|
||||
const allBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total;
|
||||
if (allBytes + addedBytes > config.maxTotalBytes) throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据");
|
||||
const updated = database.sqlite.prepare("UPDATE expenses SET paid_at=?, amount_cents=?, note=?, invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL")
|
||||
.run(paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, id, input.version);
|
||||
if (updated.changes !== 1) conflict(database, id);
|
||||
const insert = database.sqlite.prepare("INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
for (const file of promoted) insert.run(file.id, id, file.kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id);
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username,
|
||||
action: "expense.updated", targetType: "expense", targetId: id,
|
||||
before: { paidAt: current.paidAt, amountCents: current.amountCents, note: current.note, invoiceMissingReason: current.invoiceMissingReason, version: current.version },
|
||||
after: { paidAt, amountCents, note: input.note, invoiceMissingReason, version: input.version + 1, attachmentCount: promoted.length },
|
||||
});
|
||||
if (promoted.length > 0) writeAudit(database.sqlite, {
|
||||
requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username,
|
||||
action: "expense.attachments_added", targetType: "expense", targetId: id,
|
||||
before: { invoiceCount: Number(current.invoiceCount), paymentProofCount: Number(current.paymentProofCount), version: current.version },
|
||||
after: { invoiceCount, paymentProofCount: proofCount, version: input.version + 1, files: promoted.map((file) => ({ id: file.id, name: file.originalName, size: file.sizeBytes })) },
|
||||
});
|
||||
}).immediate();
|
||||
} catch (error) {
|
||||
await cleanupPromotedFiles(database.sqlite, config, promoted);
|
||||
throw error;
|
||||
}
|
||||
return { expense: publicExpense(database, getExpense(database, id)!, true) };
|
||||
}
|
||||
|
||||
function conflict(database: DatabaseContext, id: string): never {
|
||||
const current = getExpense(database, id, true);
|
||||
if (!current) notFound("账目不存在");
|
||||
@@ -836,7 +946,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
|
||||
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
|
||||
try {
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
|
||||
// Disabled/dev installs do not contact a release endpoint, so repeated
|
||||
// checks are local status reads and should remain immediately usable.
|
||||
if (config.updateStrategy !== "disabled") {
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
|
||||
}
|
||||
const result = await checkForUpdate(database.sqlite, config);
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: request.id,
|
||||
@@ -1067,7 +1181,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
await discardStaged(files);
|
||||
throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数");
|
||||
}
|
||||
const promoted = await promoteAll(config, files);
|
||||
const promoted = await promoteAll(config, files, database.sqlite);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
try {
|
||||
@@ -1110,7 +1224,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
});
|
||||
}).immediate();
|
||||
} catch (error) {
|
||||
await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined)));
|
||||
await cleanupPromotedFiles(database.sqlite, config, promoted);
|
||||
throw error;
|
||||
}
|
||||
const created = getExpense(database, id)!;
|
||||
@@ -1119,6 +1233,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
|
||||
app.patch("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => {
|
||||
const id = z.string().uuid().parse((request.params as { id: string }).id);
|
||||
if (request.isMultipart()) {
|
||||
return updateExpenseMultipart(database, config, request, id);
|
||||
}
|
||||
const input = expenseUpdateSchema.parse(request.body);
|
||||
const paidAt = Date.parse(input.paidAt);
|
||||
if (!Number.isFinite(paidAt)) throw new AppError(400, "VALIDATION_ERROR", "支付时间无效");
|
||||
@@ -1228,7 +1345,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
throw error;
|
||||
}
|
||||
if (staged.length < 1) throw new AppError(400, "FILE_REQUIRED", "请选择至少一个附件");
|
||||
const promoted = await promoteAll(config, staged);
|
||||
const promoted = await promoteAll(config, staged, database.sqlite);
|
||||
const now = Date.now();
|
||||
try {
|
||||
database.sqlite.transaction(() => {
|
||||
@@ -1276,7 +1393,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
});
|
||||
}).immediate();
|
||||
} catch (error) {
|
||||
await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined)));
|
||||
await cleanupPromotedFiles(database.sqlite, config, promoted);
|
||||
throw error;
|
||||
}
|
||||
return { expense: publicExpense(database, getExpense(database, id)!, true) };
|
||||
@@ -1315,7 +1432,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
const updated = database.sqlite.prepare("UPDATE expenses SET invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL")
|
||||
.run(nextInvoiceMissingReason, now, request.auth!.admin.id, expense.id, input.version);
|
||||
if (updated.changes !== 1) conflict(database, expense.id);
|
||||
enqueueFileDeletion(database, attachment.storagePath, "attachment_deleted");
|
||||
enqueueFileDeletion(database.sqlite, attachment.storagePath, "attachment_deleted");
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: request.id,
|
||||
actorAdminId: request.auth!.admin.id,
|
||||
@@ -1355,10 +1472,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
WHERE a.id=? AND e.deleted_at IS NULL
|
||||
`).get(id) as AttachmentRow | undefined;
|
||||
if (!attachment) notFound("附件不存在");
|
||||
let stream: Awaited<ReturnType<typeof fileReadStream>>;
|
||||
try {
|
||||
const fileInfo = await lstat(safeStoragePath(config.filesDir, attachment.storagePath));
|
||||
if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) throw new Error("attachment type");
|
||||
} catch {
|
||||
stream = await fileReadStream(config, attachment.storagePath);
|
||||
} catch (error) {
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: request.id,
|
||||
actorAdminId: request.auth!.admin.id,
|
||||
@@ -1367,9 +1484,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
targetType: "expense",
|
||||
targetId: attachment.expenseId,
|
||||
outcome: "failure",
|
||||
metadata: { attachmentId: attachment.id, mode: "missing" },
|
||||
metadata: { attachmentId: attachment.id, mode: "unavailable" },
|
||||
});
|
||||
throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
|
||||
throw error;
|
||||
}
|
||||
const download = (request.query as { download?: string }).download === "1";
|
||||
const inline = !download && (attachment.mimeType.startsWith("image/") || attachment.mimeType === "application/pdf");
|
||||
@@ -1393,7 +1510,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
reply.header("X-Frame-Options", "SAMEORIGIN");
|
||||
}
|
||||
reply.header("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`);
|
||||
return reply.send(await fileReadStream(config, attachment.storagePath));
|
||||
return reply.send(stream);
|
||||
});
|
||||
|
||||
app.delete("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => {
|
||||
@@ -1484,7 +1601,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
const current = database.sqlite.prepare("SELECT version, deleted_at AS deletedAt FROM expenses WHERE id=?").get(id) as { version: number; deletedAt: number | null } | undefined;
|
||||
if (!current || !current.deletedAt) notFound("回收站中没有该账目");
|
||||
if (current.version !== input.version) conflict(database, id);
|
||||
for (const attachment of attachmentRows) enqueueFileDeletion(database, attachment.storagePath, "expense_purged");
|
||||
for (const attachment of attachmentRows) enqueueFileDeletion(database.sqlite, attachment.storagePath, "expense_purged");
|
||||
const jobs = database.sqlite.prepare("SELECT id, status, file_path AS filePath, snapshot_json AS snapshotJson FROM export_jobs WHERE status IN ('queued','building','ready')").all() as Array<{ id: string; status: string; filePath: string | null; snapshotJson: string }>;
|
||||
for (const job of jobs) {
|
||||
const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot;
|
||||
@@ -1642,7 +1759,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
|
||||
const hasWeb = existsSync(config.webDir);
|
||||
if (hasWeb) {
|
||||
await app.register(fastifyStatic, { root: config.webDir, wildcard: false });
|
||||
// Serve the Vite asset graph as well as the SPA entry. API routes are
|
||||
// registered above and remain authoritative for /api/* paths.
|
||||
await app.register(fastifyStatic, { root: config.webDir, wildcard: true, index: "index.html" });
|
||||
}
|
||||
// Keep API errors structured even when the production frontend has not been
|
||||
// built yet (for example in a clean CI checkout or an API-only process).
|
||||
|
||||
Reference in New Issue
Block a user