release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+142 -23
View File
@@ -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).