feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s

This commit is contained in:
Qiufeng
2026-08-29 01:02:29 +08:00
commit 9719429f4a
62 changed files with 29200 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, createWriteStream } from "node:fs";
import { open, readdir, rename, rm, stat, unlink } from "node:fs/promises";
import path from "node:path";
import { ZipArchive } from "archiver";
import type Database from "better-sqlite3";
import ExcelJS from "exceljs";
import type { AppConfig } from "./config.js";
import { readStorageFile, safeStoragePath, sanitizeOriginalName } from "./files.js";
export type ExportAttachment = {
id: string;
kind: "payment_proof" | "invoice";
originalName: string;
mimeType: string;
storagePath: string;
sizeBytes: number;
sha256: string;
};
export type ExportExpense = {
id: string;
paidAt: number;
amountCents: number;
note: string;
invoiceMissingReason: string | null;
status: "unreimbursed" | "reimbursed";
attachments: ExportAttachment[];
};
export type ExportSnapshot = { expenses: ExportExpense[]; includeManifest?: boolean };
// The application is intentionally single-instance, but a queued export can
// still be triggered twice by a retry or two browser tabs. Keep one builder
// per job so both calls cannot write the same .part file concurrently.
const activeExportBuilds = new Set<string>();
export function safeExcelText(value: string): string {
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").slice(0, 32_767);
return /^[\s\u0000-\u001f]*[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned;
}
function dateParts(timestamp: number, timezone: string): { display: string; compact: string } {
const formatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const pieces = Object.fromEntries(formatter.formatToParts(timestamp).map((part) => [part.type, part.value]));
return {
display: `${pieces.year}-${pieces.month}-${pieces.day} ${pieces.hour}:${pieces.minute}`,
compact: `${pieces.year}${pieces.month}${pieces.day}`,
};
}
function uniqueAttachmentName(attachment: ExportAttachment, seen: Set<string>): string {
const parsed = path.parse(sanitizeOriginalName(attachment.originalName));
const fallbackExtension = path.extname(attachment.storagePath);
const extension = (parsed.ext || fallbackExtension).slice(0, 16);
const base = (parsed.name || attachment.kind).slice(0, 100);
if ([base, extension].some((part) => part.includes("/") || part.includes("\\") || part === "." || part === "..")) {
throw new Error("附件文件名包含非法路径片段");
}
let candidate = `${base}${extension}`;
let counter = 2;
while (seen.has(candidate.toLocaleLowerCase("und"))) candidate = `${base}_${counter++}${extension}`;
seen.add(candidate.toLocaleLowerCase("und"));
return candidate;
}
async function workbookBuffer(snapshot: ExportSnapshot, config: AppConfig): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = "TallyNote";
workbook.created = new Date();
const sheet = workbook.addWorksheet("报销清单", { views: [{ state: "frozen", ySplit: 1 }] });
sheet.columns = [
{ header: "序号", key: "sequence", width: 8 },
{ header: "支付时间", key: "paidAt", width: 22 },
{ header: "金额(元)", key: "amount", width: 16 },
{ header: "备注", key: "note", width: 44 },
{ header: "状态", key: "status", width: 14 },
{ header: "记录 ID", key: "id", width: 38 },
{ header: "付款凭证", key: "proofs", width: 38 },
{ header: "发票", key: "invoices", width: 38 },
{ header: "无发票原因", key: "invoiceMissingReason", width: 44 },
];
sheet.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
sheet.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4D43" } };
sheet.getRow(1).height = 24;
snapshot.expenses.forEach((expense, index) => {
const row = sheet.addRow({
sequence: index + 1,
paidAt: dateParts(expense.paidAt, config.timezone).display,
amount: expense.amountCents / 100,
note: safeExcelText(expense.note),
status: expense.status === "reimbursed" ? "已报销" : "未报销",
id: expense.id,
proofs: safeExcelText(expense.attachments.filter((item) => item.kind === "payment_proof").map((item) => item.originalName).join(";")),
invoices: safeExcelText(expense.attachments.filter((item) => item.kind === "invoice").map((item) => item.originalName).join(";")),
invoiceMissingReason: safeExcelText(expense.invoiceMissingReason || ""),
});
row.getCell("amount").numFmt = '¥#,##0.00';
row.alignment = { vertical: "top", wrapText: true };
});
const totalRow = sheet.addRow({
sequence: "合计",
amount: snapshot.expenses.reduce((sum, expense) => sum + expense.amountCents, 0) / 100,
});
totalRow.font = { bold: true };
totalRow.getCell("amount").numFmt = '¥#,##0.00';
sheet.autoFilter = { from: "A1", to: "I1" };
return Buffer.from(await workbook.xlsx.writeBuffer());
}
export async function buildExportJob(sqlite: Database.Database, config: AppConfig, jobId: string): Promise<void> {
if (activeExportBuilds.has(jobId)) return;
activeExportBuilds.add(jobId);
try {
await buildExportJobOnce(sqlite, config, jobId);
} finally {
activeExportBuilds.delete(jobId);
}
}
async function buildExportJobOnce(sqlite: Database.Database, config: AppConfig, jobId: string): Promise<void> {
const job = sqlite.prepare("SELECT snapshot_json AS snapshotJson FROM export_jobs WHERE id=? AND status IN ('queued','building')").get(jobId) as { snapshotJson: string } | undefined;
if (!job) return;
sqlite.prepare("UPDATE export_jobs SET status='building', error_message=NULL WHERE id=?").run(jobId);
// Use a fresh O_EXCL path for every build. A deterministic `.part` path can
// be pre-created as a symlink by another local process and then followed by
// createWriteStream. The final rename remains atomic and replaces only the
// destination entry itself.
const partialPath = path.join(config.exportsDir, `${jobId}.zip.part-${randomUUID()}`);
const finalPath = path.join(config.exportsDir, `${jobId}.zip`);
try {
const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot;
const output = createWriteStream(partialPath, { flags: "wx", mode: 0o600 });
const archive = new ZipArchive({ zlib: { level: 6 } });
const completed = new Promise<void>((resolve, reject) => {
output.on("close", resolve);
output.on("error", reject);
archive.on("warning", reject);
archive.on("error", reject);
});
archive.pipe(output);
archive.append(await workbookBuffer(snapshot, config), { name: "报销清单.xlsx" });
if (snapshot.includeManifest === true) {
const manifest = {
generatedAt: new Date().toISOString(),
records: snapshot.expenses.map((expense) => ({
id: expense.id,
paidAt: dateParts(expense.paidAt, config.timezone).display,
amountCents: expense.amountCents,
invoiceMissingReason: expense.invoiceMissingReason || null,
attachments: expense.attachments.map((attachment) => ({
id: attachment.id,
kind: attachment.kind,
originalName: attachment.originalName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
sha256: attachment.sha256,
})),
})),
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
}
for (const [index, expense] of snapshot.expenses.entries()) {
const date = dateParts(expense.paidAt, config.timezone).compact;
const folder = `${String(index + 1).padStart(3, "0")}_${date}_${(expense.amountCents / 100).toFixed(2)}_${expense.id.slice(0, 8)}`;
const seen = new Set<string>();
for (const attachment of expense.attachments) {
const group = attachment.kind === "payment_proof" ? "付款凭证" : "发票";
const fileName = uniqueAttachmentName(attachment, seen);
const absolute = safeStoragePath(config.filesDir, attachment.storagePath);
const bytes = await readStorageFile(config, attachment.storagePath);
const digest = createHash("sha256").update(bytes).digest("hex");
if (bytes.length !== attachment.sizeBytes || digest !== attachment.sha256) {
throw new Error(`附件校验失败:${attachment.id}`);
}
archive.append(bytes, { name: `${folder}/${group}/${fileName}` });
}
}
await archive.finalize();
await completed;
await rename(partialPath, finalPath);
// Open without following symlinks and keep the descriptor for the digest
// and size read. This closes the check/use gap around the published file.
const handle = await open(finalPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
let bytes: Buffer;
let info;
try {
info = await handle.stat();
if (!info.isFile()) throw new Error("导出文件类型无效");
bytes = await handle.readFile();
} finally {
await handle.close();
}
const published = sqlite.prepare(`
UPDATE export_jobs SET status='ready', file_path=?, size_bytes=?, sha256=?, ready_at=?
WHERE id=? AND status='building'
`).run(path.basename(finalPath), info.size, createHash("sha256").update(bytes).digest("hex"), Date.now(), jobId);
if (published.changes !== 1) await rm(finalPath, { force: true });
} catch (error) {
await rm(partialPath, { force: true });
await rm(finalPath, { force: true });
// Never expose filesystem paths, attachment IDs, or raw OS errors through
// the export status API. Keep a small allowlist of actionable messages.
const raw = error instanceof Error ? error.message : "";
const safe = raw.startsWith("附件校验失败") || raw.includes("ENOENT")
? "导出失败:附件文件缺失或校验不通过"
: "导出失败:服务器无法生成导出文件";
sqlite.prepare("UPDATE export_jobs SET status='failed', error_message=? WHERE id=? AND status='building'").run(safe, jobId);
}
}
export function insertExportJob(
sqlite: Database.Database,
config: AppConfig,
input: { adminId: string; sessionHash: string; selection: unknown; snapshot: ExportSnapshot },
): string {
const id = randomUUID();
const now = Date.now();
sqlite.prepare(`
INSERT INTO export_jobs (
id, admin_id, session_hash, status, selection_json, snapshot_json,
file_name, created_at, expires_at
) VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?)
`).run(
id,
input.adminId,
input.sessionHash,
JSON.stringify(input.selection),
JSON.stringify(input.snapshot),
`TallyNote_报销资料_${id.slice(0, 8)}.zip`,
now,
now + config.exportTtlMs,
);
return id;
}
export async function resumeExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const jobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>;
for (const job of jobs) await buildExportJob(sqlite, config, job.id);
}
export async function expireExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const rows = sqlite.prepare("SELECT id, file_path AS filePath FROM export_jobs WHERE status != 'expired' AND expires_at <= ?").all(Date.now()) as Array<{ id: string; filePath: string | null }>;
for (const row of rows) {
if (row.filePath) {
await unlink(safeStoragePath(config.exportsDir, row.filePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(row.id);
}
}
export async function cleanupOrphanedExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const referenced = new Set((sqlite.prepare("SELECT file_path AS filePath FROM export_jobs WHERE status='ready' AND file_path IS NOT NULL AND expires_at > ?").all(Date.now()) as Array<{ filePath: string }>).map((row) => row.filePath));
const activeJobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>;
for (const job of activeJobs) {
referenced.add(`${job.id}.zip`);
}
const cutoff = Date.now() - 10 * 60 * 1000;
for (const entry of await readdir(config.exportsDir, { withFileTypes: true })) {
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const target = path.join(config.exportsDir, entry.name);
const info = await stat(target).catch(() => null);
const belongsToActiveBuild = activeJobs.some((job) => entry.name.startsWith(`${job.id}.zip.part-`));
if (info && info.mtimeMs < cutoff && !referenced.has(entry.name) && !belongsToActiveBuild) await rm(target, { force: true });
}
}