Files
TallyNote/server/app.ts
T
Qiufeng 4f9629b089
TallyNote release / linux-x64 (push) Successful in 6m56s
fix: allow reverse proxy login
2026-09-03 15:27:10 +08:00

1863 lines
95 KiB
TypeScript

import { existsSync } from "node:fs";
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";
import cookie from "@fastify/cookie";
import helmet from "@fastify/helmet";
import multipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { z, ZodError } from "zod";
import {
adminStatusSchema,
amountToCents,
attachmentKindSchema,
attachmentDeleteSchema,
changePasswordSchema,
createAdminSchema,
expenseInputSchema,
expenseStatusSchema,
expenseUpdateSchema,
exportRequestSchema,
loginSchema,
permanentDeleteSchema,
statusUpdateSchema,
updateApplySchema,
updateDownloadSchema,
versionSchema,
type AttachmentKind,
type ExpenseStatus,
type UpdateJobStatus,
} from "../shared/contracts.js";
import { writeAudit } from "./audit.js";
import type { AppConfig } from "./config.js";
import type { DatabaseContext } from "./db/index.js";
import { AppError, errorPayload, notFound } from "./errors.js";
import { buildExportJob, expireExports, insertExportJob, type ExportExpense, type ExportSnapshot } from "./exporter.js";
import {
discardStaged,
fileReadStream,
processFileDeletions,
promoteStagedFile,
safeReadStream,
safeStoragePath,
stageMultipartFile,
type StagedFile,
} from "./files.js";
import {
constantTimeEqual,
hashPassword,
normalizeUsername,
randomToken,
sha256,
temporaryPassword,
validateNewPassword,
verifyPassword,
} from "./security.js";
import {
ACTIVE_UPDATE_STATUSES,
checkForUpdate,
publicCheckFromCache,
publicUpdateJob,
reconcileOrphanedUpdateJobs,
readCachedRelease,
writeUpdateRequest,
type UpdateRequest,
} from "./update-service.js";
type AdminRow = {
id: string;
username: string;
usernameNorm: string;
displayName: string;
passwordHash: string;
status: "active" | "disabled";
mustChangePassword: number;
authVersion: number;
version: number;
createdAt: number;
lastLoginAt: number | null;
disabledAt: number | null;
};
type AuthContext = {
tokenHash: string;
csrfHash: string;
admin: AdminRow;
};
declare module "fastify" {
interface FastifyRequest {
auth?: AuthContext;
}
}
const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const sessionCookie = "tally_session";
const csrfCookie = "tally_csrf";
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState {
let states = updateRateStates.get(database);
if (!states) {
states = new Map();
updateRateStates.set(database, states);
}
let state = states.get(adminId);
if (!state) {
state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 };
states.set(adminId, state);
}
return state;
}
function enforceUpdateCooldown(
database: DatabaseContext["sqlite"],
config: AppConfig,
adminId: string,
operation: "check" | "download" | "apply",
reply: FastifyReply,
): number {
const state = updateRateState(database, adminId);
const now = Date.now();
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs;
if (cooldown > 0 && previous > 0 && now - previous < cooldown) {
const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000));
reply.header("Retry-After", retryAfter);
throw new AppError(429, "UPDATE_RATE_LIMITED", operation === "check"
? "检查更新过于频繁,请稍后再试"
: "更新操作过于频繁,请稍后再试");
}
if (operation === "check") state.checkedAt = now;
else if (operation === "download") state.downloadedAt = now;
else state.appliedAt = now;
return now;
}
function adminSelect(alias = ""): string {
const column = (name: string) => alias ? `${alias}.${name}` : name;
return `
${column("id")}, ${column("username")}, ${column("username_norm")} AS usernameNorm, ${column("display_name")} AS displayName,
${column("password_hash")} AS passwordHash, ${column("status")}, ${column("must_change_password")} AS mustChangePassword,
${column("auth_version")} AS authVersion, ${column("version")}, ${column("created_at")} AS createdAt,
${column("last_login_at")} AS lastLoginAt, ${column("disabled_at")} AS disabledAt
`;
}
function publicAdmin(admin: AdminRow) {
return {
id: admin.id,
username: admin.username,
displayName: admin.displayName,
status: admin.status,
mustChangePassword: Boolean(admin.mustChangePassword),
version: admin.version,
createdAt: admin.createdAt,
lastLoginAt: admin.lastLoginAt,
disabledAt: admin.disabledAt,
};
}
function cookieOptions(config: AppConfig, httpOnly: boolean) {
return {
path: "/",
httpOnly,
secure: config.cookieSecure,
sameSite: "strict" as const,
};
}
function clearSessionCookies(reply: FastifyReply, config: AppConfig): void {
reply.clearCookie(sessionCookie, cookieOptions(config, true));
reply.clearCookie(csrfCookie, cookieOptions(config, false));
}
function createSession(database: DatabaseContext, config: AppConfig, admin: AdminRow, reply: FastifyReply): AuthContext {
const token = randomToken();
const csrf = randomToken();
const tokenHash = sha256(token);
const csrfHash = sha256(csrf);
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO sessions (
token_hash, admin_id, csrf_hash, auth_version, created_at,
last_seen_at, idle_expires_at, absolute_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(tokenHash, admin.id, csrfHash, admin.authVersion, now, now, now + config.sessionIdleMs, now + config.sessionAbsoluteMs);
reply.setCookie(sessionCookie, token, { ...cookieOptions(config, true), maxAge: Math.floor(config.sessionAbsoluteMs / 1000) });
reply.setCookie(csrfCookie, csrf, { ...cookieOptions(config, false), maxAge: Math.floor(config.sessionAbsoluteMs / 1000) });
return { tokenHash, csrfHash, admin };
}
function authenticate(request: FastifyRequest, database: DatabaseContext, config: AppConfig): AuthContext {
const token = request.cookies[sessionCookie];
if (!token || token.length > 128) throw new AppError(401, "AUTH_REQUIRED", "请先登录");
const tokenHash = sha256(token);
const row = database.sqlite.prepare(`
SELECT s.token_hash AS tokenHash, s.csrf_hash AS csrfHash,
s.auth_version AS sessionAuthVersion, s.last_seen_at AS lastSeenAt,
s.idle_expires_at AS idleExpiresAt, s.absolute_expires_at AS absoluteExpiresAt,
${adminSelect("a")}
FROM sessions s JOIN admins a ON a.id=s.admin_id WHERE s.token_hash=?
`).get(tokenHash) as (AdminRow & {
tokenHash: string;
csrfHash: string;
sessionAuthVersion: number;
lastSeenAt: number;
idleExpiresAt: number;
absoluteExpiresAt: number;
}) | undefined;
const now = Date.now();
if (!row || row.status !== "active" || row.sessionAuthVersion !== row.authVersion || row.idleExpiresAt <= now || row.absoluteExpiresAt <= now) {
if (row) database.sqlite.prepare("DELETE FROM sessions WHERE token_hash=?").run(tokenHash);
throw new AppError(401, "AUTH_REQUIRED", "登录已失效,请重新登录");
}
if (now - row.lastSeenAt >= 5 * 60 * 1000) {
database.sqlite.prepare("UPDATE sessions SET last_seen_at=?, idle_expires_at=? WHERE token_hash=?")
.run(now, Math.min(now + config.sessionIdleMs, row.absoluteExpiresAt), tokenHash);
}
const auth = { tokenHash: row.tokenHash, csrfHash: row.csrfHash, admin: row };
request.auth = auth;
return auth;
}
function assertCsrf(request: FastifyRequest, auth: AuthContext): void {
const cookieToken = request.cookies[csrfCookie] ?? "";
const headerToken = typeof request.headers["x-csrf-token"] === "string" ? request.headers["x-csrf-token"] : "";
if (!cookieToken || !headerToken || !constantTimeEqual(cookieToken, headerToken) || !constantTimeEqual(sha256(cookieToken), auth.csrfHash)) {
throw new AppError(403, "CSRF_INVALID", "请求安全令牌无效,请刷新页面后重试");
}
}
function guard(database: DatabaseContext, config: AppConfig, options: { allowPasswordChange?: boolean } = {}) {
return async (request: FastifyRequest) => {
const auth = authenticate(request, database, config);
if (unsafeMethods.has(request.method)) assertCsrf(request, auth);
if (!options.allowPasswordChange && auth.admin.mustChangePassword) {
throw new AppError(403, "PASSWORD_CHANGE_REQUIRED", "首次登录需要先修改密码");
}
};
}
function expenseBaseSelect(): string {
return `
e.id, e.paid_at AS paidAt, e.amount_cents AS amountCents, e.note,
e.invoice_missing_reason AS invoiceMissingReason, e.status,
e.version, e.created_at AS createdAt, e.updated_at AS updatedAt,
e.reimbursed_at AS reimbursedAt, e.deleted_at AS deletedAt,
creator.display_name AS createdByName, updater.display_name AS updatedByName,
SUM(CASE WHEN a.kind='payment_proof' THEN 1 ELSE 0 END) AS paymentProofCount,
SUM(CASE WHEN a.kind='invoice' THEN 1 ELSE 0 END) AS invoiceCount
`;
}
type ExpenseRow = {
id: string;
paidAt: number;
amountCents: number;
note: string;
invoiceMissingReason: string | null;
status: ExpenseStatus;
version: number;
createdAt: number;
updatedAt: number;
reimbursedAt: number | null;
deletedAt: number | null;
createdByName: string;
updatedByName: string;
paymentProofCount: number;
invoiceCount: number;
};
type AttachmentRow = {
id: string;
expenseId: string;
kind: AttachmentKind;
storagePath: string;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
createdAt: number;
};
function listAttachments(database: DatabaseContext, expenseId: string): AttachmentRow[] {
return database.sqlite.prepare(`
SELECT id, expense_id AS expenseId, kind, storage_path AS storagePath,
original_name AS originalName, mime_type AS mimeType, size_bytes AS sizeBytes,
sha256, created_at AS createdAt
FROM attachments WHERE expense_id=? ORDER BY created_at, id
`).all(expenseId) as AttachmentRow[];
}
function publicAttachment(row: AttachmentRow) {
return {
id: row.id,
expenseId: row.expenseId,
kind: row.kind,
originalName: row.originalName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
sha256: row.sha256,
createdAt: row.createdAt,
previewable: row.mimeType.startsWith("image/") || row.mimeType === "application/pdf",
};
}
function getExpense(database: DatabaseContext, id: string, includeDeleted = false): ExpenseRow | undefined {
return database.sqlite.prepare(`
SELECT ${expenseBaseSelect()}
FROM expenses e
LEFT JOIN attachments a ON a.expense_id=e.id
JOIN admins creator ON creator.id=e.created_by
JOIN admins updater ON updater.id=e.updated_by
WHERE e.id=? ${includeDeleted ? "" : "AND e.deleted_at IS NULL"}
GROUP BY e.id
`).get(id) as ExpenseRow | undefined;
}
function publicExpense(database: DatabaseContext, row: ExpenseRow, withAttachments = false) {
return {
...row,
paymentProofCount: Number(row.paymentProofCount),
invoiceCount: Number(row.invoiceCount),
...(withAttachments ? { attachments: listAttachments(database, row.id).map(publicAttachment) } : {}),
};
}
function normalizeInvoiceMissingReason(value: string | null | undefined): string | null {
const normalized = typeof value === "string" ? value.trim() : "";
return normalized || null;
}
function assertInvoiceCoverage(invoiceCount: number, reason: string | null, missingStatus = 400): void {
const normalizedReason = normalizeInvoiceMissingReason(reason);
if (invoiceCount > 0 && normalizedReason) {
throw new AppError(400, "INVOICE_REASON_WITH_INVOICE", "已有发票时不能填写无发票原因");
}
if (invoiceCount < 1 && !normalizedReason) {
throw new AppError(missingStatus, "INVOICE_OR_REASON_REQUIRED", "请上传发票,或勾选“无发票”并填写原因");
}
}
export function zonedMonthBounds(month: string, timezone: string): [number, number] {
const match = /^(\d{4})-(\d{2})$/.exec(month);
if (!match) throw new AppError(400, "VALIDATION_ERROR", "月份格式无效");
const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
if (monthIndex < 0 || monthIndex > 11) throw new AppError(400, "VALIDATION_ERROR", "月份格式无效");
const toUtc = (targetYear: number, targetMonth: number) => {
const target = Date.UTC(targetYear, targetMonth, 1, 0, 0, 0);
let guess = target;
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
for (let iteration = 0; iteration < 4; iteration += 1) {
const parts = Object.fromEntries(formatter.formatToParts(guess).map((part) => [part.type, part.value]));
const observed = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
const difference = target - observed;
guess += difference;
if (difference === 0) break;
}
return guess;
};
return [toUtc(year, monthIndex), toUtc(year, monthIndex + 1)];
}
const listQuerySchema = z.object({
month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/),
status: expenseStatusSchema.default("unreimbursed"),
query: z.string().max(200).default(""),
missingInvoice: z.enum(["true", "false"]).default("false").transform((value) => value === "true"),
}).strict();
function filteredExpenses(database: DatabaseContext, config: AppConfig, query: z.infer<typeof listQuerySchema>): ExpenseRow[] {
const [start, end] = zonedMonthBounds(query.month, config.timezone);
const escaped = query.query.replace(/[\\%_]/g, "\\$&");
return database.sqlite.prepare(`
SELECT ${expenseBaseSelect()}
FROM expenses e
LEFT JOIN attachments a ON a.expense_id=e.id
JOIN admins creator ON creator.id=e.created_by
JOIN admins updater ON updater.id=e.updated_by
WHERE e.deleted_at IS NULL AND e.status=? AND e.paid_at>=? AND e.paid_at<?
AND e.note LIKE ? ESCAPE '\\'
${query.missingInvoice ? "AND NOT EXISTS (SELECT 1 FROM attachments ai WHERE ai.expense_id=e.id AND ai.kind='invoice')" : ""}
GROUP BY e.id ORDER BY e.paid_at DESC, e.id DESC
`).all(query.status, start, end, `%${escaped}%`) as ExpenseRow[];
}
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());
}
function reauthKey(request: FastifyRequest, adminId: string): string {
return sha256(`reauth:${adminId}:${request.ip}`);
}
function assertReauthAllowed(database: DatabaseContext, request: FastifyRequest, adminId: string): void {
const row = database.sqlite.prepare("SELECT blocked_until AS blockedUntil FROM login_attempts WHERE key_hash=?").get(reauthKey(request, adminId)) as { blockedUntil: number | null } | undefined;
if (row?.blockedUntil && row.blockedUntil > Date.now()) throw new AppError(429, "REAUTH_RATE_LIMITED", "密码确认尝试过多,请稍后再试");
}
function recordReauthFailure(database: DatabaseContext, request: FastifyRequest, adminId: string): void {
const key = reauthKey(request, adminId);
const now = Date.now();
const current = database.sqlite.prepare("SELECT window_start AS windowStart, failures FROM login_attempts WHERE key_hash=?").get(key) as { windowStart: number; failures: number } | undefined;
const fresh = !current || current.windowStart <= now - 15 * 60 * 1000;
const failures = fresh ? 1 : current.failures + 1;
const blockedUntil = failures >= 5 ? now + 15 * 60 * 1000 : null;
database.sqlite.prepare(`
INSERT INTO login_attempts(key_hash, window_start, failures, blocked_until) VALUES (?, ?, ?, ?)
ON CONFLICT(key_hash) DO UPDATE SET window_start=excluded.window_start, failures=excluded.failures, blocked_until=excluded.blocked_until
`).run(key, fresh ? now : current.windowStart, failures, blockedUntil);
}
function clearReauthFailures(database: DatabaseContext, request: FastifyRequest, adminId: string): void {
database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash=?").run(reauthKey(request, adminId));
}
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 (!allowedFields.has(part.fieldname)) {
throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段");
}
if (part.fieldname in fields) {
throw new AppError(400, "DUPLICATE_FIELD", "表单字段不能重复");
}
fields[part.fieldname] = String(part.value);
continue;
}
if (files.length >= config.maxFilesPerRequest) throw new AppError(413, "TOO_MANY_FILES", "一次请求上传的文件过多");
const kind = part.fieldname === "paymentProofs" ? "payment_proof" : part.fieldname === "invoices" ? "invoice" : null;
if (!kind) {
part.file.resume();
throw new AppError(400, "UNKNOWN_FILE_FIELD", "未知的附件字段");
}
files.push(await stageMultipartFile(config, part, kind));
}
return { fields, files };
} catch (error) {
await discardStaged(files);
throw error;
}
}
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) {
// 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("账目不存在");
throw new AppError(409, "VERSION_CONFLICT", "记录已被其他管理员修改", {
currentVersion: current.version,
current: publicExpense(database, current, true),
});
}
export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and
// Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an
// explicitly opted-in HTTP deployment must remain HTTP all the way through
// the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the
// plain HTTP listener appears as a blank page. Keep the transport-sensitive
// headers protocol-aware while retaining the other hardening headers.
const secureOrigin = config.publicOrigin.startsWith("https:");
const app = Fastify({
logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false,
// Fastify's runtime accepts a numeric hop count, while its v5 typings do
// not expose that overload. Keep the validated numeric value and bridge
// the declaration at this boundary.
trustProxy: config.trustProxy as any,
bodyLimit: config.maxRecordBytes + 2 * 1024 * 1024,
requestIdHeader: false,
genReqId: () => randomUUID(),
});
const dummyPasswordHash = await hashPassword(randomToken());
await app.register(cookie);
await app.register(helmet, {
...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}),
frameguard: { action: "deny" },
referrerPolicy: { policy: "no-referrer" },
...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }),
crossOriginResourcePolicy: { policy: "same-origin" },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", "blob:", "data:"],
objectSrc: ["'none'"],
frameSrc: ["'self'"],
"frame-ancestors": ["'none'"],
"base-uri": ["'none'"],
"form-action": ["'self'"],
...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}),
},
},
});
await app.register(multipart, {
limits: { fileSize: config.maxFileBytes, files: config.maxFilesPerRequest, fields: 20, parts: config.maxFilesPerRequest + 20 },
throwFileSizeLimit: true,
});
// Financial records, attachment bytes and update metadata must never be
// retained by a browser, reverse proxy or shared cache. Keep this global so
// future authenticated routes inherit the same privacy boundary.
app.addHook("onSend", async (request, reply, payload) => {
if (request.url.split("?", 1)[0]!.startsWith("/api/")) {
reply.header("Cache-Control", "no-store");
reply.header("Pragma", "no-cache");
reply.header("Vary", "Cookie");
}
return payload;
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error));
if (error instanceof ZodError) {
const appError = new AppError(400, "VALIDATION_ERROR", "提交内容不符合要求", error.issues);
return reply.code(400).send(errorPayload(request, appError));
}
if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") {
const appError = new AppError(413, "FILE_TOO_LARGE", "单个文件超过大小限制");
return reply.code(413).send(errorPayload(request, appError));
}
const fastifyError = error as { code?: string; statusCode?: number };
if (fastifyError.code === "FST_ERR_CTP_INVALID_JSON_BODY" || fastifyError.code === "FST_ERR_CTP_EMPTY_JSON_BODY") {
const appError = new AppError(400, "INVALID_JSON", "请求 JSON 格式无效");
return reply.code(400).send(errorPayload(request, appError));
}
if (fastifyError.statusCode === 413 || ["FST_REQ_BODY_TOO_LARGE", "FST_ERR_CTP_BODY_TOO_LARGE", "FST_FIELDS_LIMIT", "FST_FILES_LIMIT", "FST_PARTS_LIMIT"].includes(fastifyError.code ?? "")) {
const appError = new AppError(413, "REQUEST_TOO_LARGE", "请求内容超过大小或数量限制");
return reply.code(413).send(errorPayload(request, appError));
}
if (typeof fastifyError.statusCode === "number" && fastifyError.statusCode >= 400 && fastifyError.statusCode < 500) {
const appError = new AppError(fastifyError.statusCode, "BAD_REQUEST", "请求无法处理");
return reply.code(fastifyError.statusCode).send(errorPayload(request, appError));
}
request.log.error(error);
return reply.code(500).send(errorPayload(request, new AppError(500, "INTERNAL_ERROR", "服务器处理请求时发生错误")));
});
app.get("/health", async () => ({ status: "ok", initialized: Boolean(database.sqlite.prepare("SELECT 1 FROM admins LIMIT 1").get()) }));
app.get("/api/auth/status", async () => ({ initialized: Boolean(database.sqlite.prepare("SELECT 1 FROM admins LIMIT 1").get()), timezone: config.timezone }));
app.post("/api/auth/login", { bodyLimit: 16 * 1024 }, async (request, reply) => {
const input = loginSchema.parse(request.body);
const normalized = normalizeUsername(input.username);
const now = Date.now();
const ipKey = sha256(`ip:${request.ip}`);
const pairKey = sha256(`pair:${request.ip}:${normalized}`);
const limits = [
{ key: ipKey, maximum: 20 },
{ key: pairKey, maximum: 5 },
];
for (const limit of limits) {
const row = database.sqlite.prepare("SELECT failures, blocked_until AS blockedUntil FROM login_attempts WHERE key_hash=?").get(limit.key) as { failures: number; blockedUntil: number | null } | undefined;
if (row?.blockedUntil && row.blockedUntil > now) {
reply.header("Retry-After", Math.ceil((row.blockedUntil - now) / 1000));
throw new AppError(429, "LOGIN_RATE_LIMITED", "登录尝试过多,请稍后再试");
}
}
const admin = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE username_norm=?`).get(normalized) as AdminRow | undefined;
const valid = await verifyPassword(admin?.passwordHash ?? dummyPasswordHash, input.password);
if (!admin || admin.status !== "active" || !valid) {
const updateLimit = database.sqlite.transaction(() => {
for (const limit of limits) {
const current = database.sqlite.prepare("SELECT window_start AS windowStart, failures FROM login_attempts WHERE key_hash=?").get(limit.key) as { windowStart: number; failures: number } | undefined;
const freshWindow = !current || current.windowStart <= now - 15 * 60 * 1000;
const failures = freshWindow ? 1 : current.failures + 1;
const blockedUntil = failures >= limit.maximum ? now + 15 * 60 * 1000 : null;
database.sqlite.prepare(`
INSERT INTO login_attempts(key_hash, window_start, failures, blocked_until) VALUES (?, ?, ?, ?)
ON CONFLICT(key_hash) DO UPDATE SET window_start=excluded.window_start, failures=excluded.failures, blocked_until=excluded.blocked_until
`).run(limit.key, freshWindow ? now : current.windowStart, failures, blockedUntil);
}
writeAudit(database.sqlite, {
requestId: request.id,
actorUsername: normalized,
action: "auth.login",
targetType: "session",
outcome: "denied",
metadata: { ipHash: sha256(request.ip) },
});
});
updateLimit();
throw new AppError(401, "INVALID_CREDENTIALS", "用户名或密码不正确");
}
database.sqlite.transaction(() => {
database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash IN (?, ?)").run(ipKey, pairKey);
database.sqlite.prepare("UPDATE admins SET last_login_at=? WHERE id=?").run(now, admin.id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: admin.id,
actorUsername: admin.username,
action: "auth.login",
targetType: "session",
outcome: "success",
});
})();
const updatedAdmin = { ...admin, lastLoginAt: now };
createSession(database, config, updatedAdmin, reply);
reply.header("Cache-Control", "no-store");
return { admin: publicAdmin(updatedAdmin) };
});
app.get("/api/auth/session", { preHandler: guard(database, config, { allowPasswordChange: true }) }, async (request, reply) => {
reply.header("Cache-Control", "no-store");
return { admin: publicAdmin(request.auth!.admin) };
});
app.post("/api/auth/logout", { preHandler: guard(database, config, { allowPasswordChange: true }) }, async (request, reply) => {
database.sqlite.transaction(() => {
database.sqlite.prepare("DELETE FROM sessions WHERE token_hash=?").run(request.auth!.tokenHash);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "auth.logout",
targetType: "session",
targetId: request.auth!.tokenHash.slice(0, 12),
});
})();
clearSessionCookies(reply, config);
return reply.code(204).send();
});
app.post("/api/auth/change-password", { preHandler: guard(database, config, { allowPasswordChange: true }), bodyLimit: 16 * 1024 }, async (request, reply) => {
const input = changePasswordSchema.parse(request.body);
const policyError = validateNewPassword(input.newPassword);
if (policyError) throw new AppError(400, "PASSWORD_POLICY_FAILED", policyError);
assertReauthAllowed(database, request, request.auth!.admin.id);
if (!await verifyPassword(request.auth!.admin.passwordHash, input.currentPassword)) {
recordReauthFailure(database, request, request.auth!.admin.id);
throw new AppError(401, "CURRENT_PASSWORD_INVALID", "当前密码不正确");
}
clearReauthFailures(database, request, request.auth!.admin.id);
if (await verifyPassword(request.auth!.admin.passwordHash, input.newPassword)) {
throw new AppError(409, "PASSWORD_REUSE_NOT_ALLOWED", "新密码不能与当前密码相同");
}
const passwordHash = await hashPassword(input.newPassword);
const now = Date.now();
database.sqlite.transaction(() => {
database.sqlite.prepare(`
UPDATE admins SET password_hash=?, must_change_password=0, auth_version=auth_version+1,
version=version+1, password_changed_at=? WHERE id=?
`).run(passwordHash, now, request.auth!.admin.id);
database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(request.auth!.admin.id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "admin.password_changed",
targetType: "admin",
targetId: request.auth!.admin.id,
});
})();
const updated = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(request.auth!.admin.id) as AdminRow;
createSession(database, config, updated, reply);
reply.header("Cache-Control", "no-store");
return { admin: publicAdmin(updated) };
});
app.get("/api/admins", { preHandler: guard(database, config) }, async () => {
const rows = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins ORDER BY created_at`).all() as AdminRow[];
return { items: rows.map(publicAdmin) };
});
app.post("/api/admins", { preHandler: guard(database, config) }, async (request, reply) => {
const input = createAdminSchema.parse(request.body);
const usernameNorm = normalizeUsername(input.username);
if ([...usernameNorm].length < 3) throw new AppError(400, "VALIDATION_ERROR", "用户名至少需要 3 个字符");
const password = temporaryPassword();
const passwordHash = await hashPassword(password);
const id = randomUUID();
const now = Date.now();
try {
database.sqlite.transaction(() => {
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at, created_by)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?, ?)
`).run(id, input.username.normalize("NFKC").trim(), usernameNorm, input.displayName, passwordHash, now, request.auth!.admin.id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "admin.created",
targetType: "admin",
targetId: id,
after: { username: input.username, displayName: input.displayName, status: "active" },
});
}).immediate();
} catch (error) {
if (String(error).includes("UNIQUE")) throw new AppError(409, "USERNAME_TAKEN", "用户名已存在");
throw error;
}
const created = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow;
reply.header("Cache-Control", "no-store");
return reply.code(201).send({ admin: publicAdmin(created), temporaryPassword: password });
});
app.patch("/api/admins/:id", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = z.object({ displayName: z.string().trim().min(1).max(80), version: z.number().int().positive() }).strict().parse(request.body);
const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined;
if (!existing) notFound("管理员不存在");
const result = database.sqlite.transaction(() => {
const update = database.sqlite.prepare("UPDATE admins SET display_name=?, version=version+1 WHERE id=? AND version=?").run(input.displayName, id, input.version);
if (update.changes !== 1) throw new AppError(409, "VERSION_CONFLICT", "管理员资料已被更新");
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "admin.updated",
targetType: "admin",
targetId: id,
before: { displayName: existing.displayName },
after: { displayName: input.displayName },
});
return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow;
})();
return { admin: publicAdmin(result) };
});
app.put("/api/admins/:id/status", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = adminStatusSchema.parse(request.body);
const result = database.sqlite.transaction(() => {
const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined;
if (!existing) notFound("管理员不存在");
if (existing.version !== input.version) throw new AppError(409, "VERSION_CONFLICT", "管理员状态已被更新");
if (existing.status === input.status) return existing;
if (id === request.auth!.admin.id && input.status === "disabled") {
throw new AppError(409, "SELF_DISABLE_FORBIDDEN", "不能停用当前登录的管理员账号");
}
if (input.status === "disabled") {
const active = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins WHERE status='active'").get() as { count: number };
if (active.count <= 1) throw new AppError(409, "LAST_ACTIVE_ADMIN", "不能停用最后一个有效管理员");
}
const now = Date.now();
database.sqlite.prepare(`
UPDATE admins SET status=?, version=version+1, auth_version=auth_version+1,
disabled_at=?, disabled_by=? WHERE id=? AND version=?
`).run(input.status, input.status === "disabled" ? now : null, input.status === "disabled" ? request.auth!.admin.id : null, id, input.version);
if (input.status === "disabled") database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: input.status === "disabled" ? "admin.disabled" : "admin.enabled",
targetType: "admin",
targetId: id,
before: { status: existing.status },
after: { status: input.status },
});
return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow;
}).immediate();
return { admin: publicAdmin(result) };
});
app.post("/api/admins/:id/reset-password", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
if (id === request.auth!.admin.id) throw new AppError(409, "SELF_RESET_FORBIDDEN", "不能重置当前登录管理员的密码,请使用修改密码功能");
const input = versionSchema.parse(request.body);
const password = temporaryPassword();
const passwordHash = await hashPassword(password);
const updated = database.sqlite.transaction(() => {
const existing = database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow | undefined;
if (!existing) notFound("管理员不存在");
const result = database.sqlite.prepare(`
UPDATE admins SET password_hash=?, must_change_password=1, auth_version=auth_version+1,
version=version+1, password_changed_at=NULL WHERE id=? AND version=?
`).run(passwordHash, id, input.version);
if (result.changes !== 1) throw new AppError(409, "VERSION_CONFLICT", "管理员资料已被更新");
database.sqlite.prepare("DELETE FROM sessions WHERE admin_id=?").run(id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "admin.password_reset",
targetType: "admin",
targetId: id,
});
return database.sqlite.prepare(`SELECT ${adminSelect()} FROM admins WHERE id=?`).get(id) as AdminRow;
})();
reply.header("Cache-Control", "no-store");
return { admin: publicAdmin(updated), temporaryPassword: password };
});
app.get("/api/update/status", { preHandler: guard(database, config) }, async (request, reply) => {
// Release metadata and task state should never be stored by an upstream
// proxy or a shared browser cache.
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
return {
...cached,
strategy: config.updateStrategy,
job: publicUpdateJob(row),
};
});
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const rateState = updateRateState(database.sqlite, request.auth!.admin.id);
const previousCheckedAt = rateState.checkedAt;
let reservedCheckedAt: number | null = null;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
// 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") {
reservedCheckedAt = enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply);
}
const result = await checkForUpdate(database.sqlite, config);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "update.checked",
targetType: "system",
metadata: {
currentVersion: result.currentVersion,
latestVersion: result.latest?.version ?? null,
compatible: result.latest?.compatible ?? false,
integrityReady: result.latest?.integrityReady ?? false,
},
});
reply.header("Cache-Control", "no-store");
return { ...result, strategy: config.updateStrategy };
} catch (error) {
// A failed upstream request is not a successful check. Release the
// reservation only when this request still owns it, so a concurrent
// successful check cannot have its cooldown overwritten.
if (reservedCheckedAt !== null && rateState.checkedAt === reservedCheckedAt) rateState.checkedAt = previousCheckedAt;
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "update.checked",
targetType: "system",
outcome: "failure",
});
throw error;
}
});
app.post("/api/update/apply", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const input = updateApplySchema.parse(request.body);
let applyAuditRecorded = false;
let applyAuditTarget: string | undefined;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") {
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
}
if (input.jobId) {
const stagedJobId = input.jobId;
const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined;
if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
const now = Date.now();
const active = database.sqlite.transaction(() => {
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined;
if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId);
if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: stagedJobId, after: { version: staged.version, staged: true } });
return { id: stagedJobId, now };
}).immediate();
applyAuditTarget = stagedJobId;
if (!staged.expectedSha256 || !/^[a-f0-9]{64}$/i.test(staged.expectedSha256)) {
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("暂存更新缺少有效校验值", Date.now(), active.id);
throw new AppError(409, "UPDATE_NOT_VERIFIED", "暂存更新缺少有效校验值,请重新下载");
}
try {
await writeUpdateRequest(config, { jobId: active.id, operation: "apply", version: staged.version, metadataUrl: config.updateMetadataUrl, assetUrl: staged.assetUrl, assetName: staged.assetName ?? "staged", expectedSha256: staged.expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
} catch {
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("无法创建系统更新请求", Date.now(), active.id);
applyAuditRecorded = true;
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure" });
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id: active.id, status: "staged", version: staged.version, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
}
// Preserve the actionable in-progress response for duplicate clicks before
// applying the per-admin cooldown.
const activeBeforeCheck = database.sqlite.prepare(`
SELECT id FROM update_jobs
WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (activeBeforeCheck) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
// Re-fetch before applying. A cached tag is only a display hint; the
// server must verify the release and digest immediately before queuing it.
const checked = await checkForUpdate(database.sqlite, config);
const requestedVersion = input.version.replace(/^v/i, "");
if (!checked.latest || checked.latest.version !== requestedVersion || !checked.latest.isNewer) {
throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
}
if (!checked.latest.compatible || !checked.latest.integrityReady) {
throw new AppError(409, "UPDATE_NOT_VERIFIED", "该版本没有匹配当前平台且可验证的发布文件");
}
const cached = readCachedRelease(database.sqlite, config);
const releaseAsset = cached?.asset;
if (!cached || !releaseAsset?.sha256 || !releaseAsset.url || cached.version !== requestedVersion) {
throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
}
const expectedSha256 = releaseAsset.sha256;
const active = database.sqlite.transaction(() => {
const existing = database.sqlite.prepare(`
SELECT id, status FROM update_jobs
WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")})
ORDER BY created_at DESC LIMIT 1
`).get(...ACTIVE_UPDATE_STATUSES) as { id: string; status: UpdateJobStatus } | undefined;
if (existing) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
const id = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO update_jobs(
id, admin_id, session_hash, request_id, requested_at, status,
version, platform, release_url, asset_name, asset_url,
expected_sha256, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
request.auth!.admin.id,
request.auth!.tokenHash,
request.id,
now,
requestedVersion,
checked.platform.target,
cached.metadataUrl,
releaseAsset.name,
releaseAsset.url,
expectedSha256,
now,
now,
);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "update.apply_requested",
targetType: "update",
targetId: id,
after: { version: requestedVersion, platform: checked.platform.target, assetName: releaseAsset.name },
});
return { id, now };
}).immediate();
applyAuditTarget = active.id;
const updateRequest: UpdateRequest = {
jobId: active.id,
operation: "apply",
version: requestedVersion,
metadataUrl: cached.metadataUrl,
assetUrl: releaseAsset.url,
assetName: releaseAsset.name,
expectedSha256,
requestedAt: active.now,
currentLink: config.currentLink,
releasesDir: config.releasesDir,
dataDir: config.dataDir,
};
try {
await writeUpdateRequest(config, updateRequest);
} catch {
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status='queued'").run("无法创建系统更新请求", Date.now(), active.id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "update.apply_requested",
targetType: "update",
targetId: active.id,
outcome: "failure",
});
applyAuditRecorded = true;
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
} catch (error) {
if (!applyAuditRecorded) {
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "update.apply_requested",
targetType: "update",
...(applyAuditTarget ? { targetId: applyAuditTarget } : {}),
outcome: "failure",
});
}
throw error;
}
});
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const input = updateDownloadSchema.parse(request.body);
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply);
const checked = await checkForUpdate(database.sqlite, config);
const version = input.version.replace(/^v/i, "");
if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
const cached = readCachedRelease(database.sqlite, config);
const cachedAsset = cached?.asset;
if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
const now = Date.now();
const id = randomUUID();
database.sqlite.transaction(() => {
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now);
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } });
}).immediate();
try {
await writeUpdateRequest(config, { jobId: id, operation: "download", version, metadataUrl: cached.metadataUrl, assetUrl: cachedAsset.url, assetName: cachedAsset.name, expectedSha256: cachedAsset.sha256, requestedAt: now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
} catch {
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法创建系统更新请求", Date.now(), id);
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
}
reply.header("Cache-Control", "no-store");
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
});
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
download_speed_bps AS downloadSpeedBps,
requested_at AS applyQueuedAt
FROM update_jobs WHERE id=? AND admin_id=?
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
if (!row) notFound("更新任务不存在");
reply.header("Cache-Control", "no-store");
return { job: publicUpdateJob(row) };
});
app.get("/api/expenses", { preHandler: guard(database, config) }, async (request) => {
const query = listQuerySchema.parse(request.query);
const rows = filteredExpenses(database, config, query);
return {
items: rows.map((row) => publicExpense(database, row)),
summary: { count: rows.length, amountCents: rows.reduce((sum, row) => sum + row.amountCents, 0) },
};
});
app.get("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const row = getExpense(database, id);
if (!row) notFound("账目不存在");
const timeline = database.sqlite.prepare(`
SELECT id, occurred_at AS occurredAt, actor_username AS actorUsername, action,
before_json AS beforeJson, after_json AS afterJson, metadata_json AS metadataJson
FROM audit_events WHERE target_type='expense' AND target_id=? ORDER BY occurred_at DESC, id DESC
`).all(id);
return { expense: publicExpense(database, row, true), timeline };
});
app.post("/api/expenses", { preHandler: guard(database, config) }, async (request, reply) => {
if (!request.isMultipart()) throw new AppError(415, "MULTIPART_REQUIRED", "新建账目必须使用附件表单提交");
const { fields, files } = await parseExpenseMultipart(request, config);
let input: z.infer<typeof expenseInputSchema>;
try {
input = expenseInputSchema.parse({
paidAt: fields.paidAt,
amount: fields.amount,
note: fields.note ?? "",
invoiceMissingReason: fields.invoiceMissingReason,
});
} catch (error) {
await discardStaged(files);
throw error;
}
const paymentProofs = files.filter((file) => file.kind === "payment_proof");
if (paymentProofs.length < 1) {
await discardStaged(files);
throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证");
}
const invoiceFiles = files.filter((file) => file.kind === "invoice");
const requestedInvoiceMissingReason = normalizeInvoiceMissingReason(input.invoiceMissingReason);
try {
assertInvoiceCoverage(invoiceFiles.length, requestedInvoiceMissingReason);
} catch (error) {
await discardStaged(files);
throw error;
}
const invoiceMissingReason = invoiceFiles.length > 0 ? null : requestedInvoiceMissingReason;
const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
if (totalBytes > config.maxRecordBytes) {
await discardStaged(files);
throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制");
}
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 id = randomUUID();
const now = Date.now();
try {
database.sqlite.transaction(() => {
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, invoice_missing_reason, status, version,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, 'unreimbursed', 1, ?, ?, ?, ?)
`).run(id, paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, now, request.auth!.admin.id);
const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total;
if (globalBytes + totalBytes > config.maxTotalBytes) {
throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据");
}
const insertAttachment = 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) {
insertAttachment.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.created",
targetType: "expense",
targetId: id,
after: {
paidAt,
amountCents,
note: input.note,
invoiceMissingReason,
status: "unreimbursed",
attachmentCount: promoted.length,
paymentProofCount: paymentProofs.length,
invoiceCount: invoiceFiles.length,
attachmentKinds: promoted.map((file) => file.kind),
},
});
}).immediate();
} catch (error) {
await cleanupPromotedFiles(database.sqlite, config, promoted);
throw error;
}
const created = getExpense(database, id)!;
return reply.code(201).send({ expense: publicExpense(database, created, true) });
});
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", "支付时间无效");
let amountCents: number;
try {
amountCents = amountToCents(input.amount);
} catch {
throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数");
}
const requestedInvoiceMissingReason = input.invoiceMissingReason === undefined
? undefined
: normalizeInvoiceMissingReason(input.invoiceMissingReason);
const now = Date.now();
database.sqlite.transaction(() => {
const before = getExpense(database, id);
if (!before) notFound("账目不存在");
if (before.version !== input.version) conflict(database, id);
const invoiceMissingReason = requestedInvoiceMissingReason === undefined
? before.invoiceMissingReason
: requestedInvoiceMissingReason;
assertInvoiceCoverage(Number(before.invoiceCount), invoiceMissingReason);
const result = 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 (result.changes !== 1) conflict(database, 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: before.paidAt,
amountCents: before.amountCents,
note: before.note,
invoiceMissingReason: before.invoiceMissingReason,
version: before.version,
},
after: { paidAt, amountCents, note: input.note, invoiceMissingReason, version: input.version + 1 },
});
}).immediate();
return { expense: publicExpense(database, getExpense(database, id)!, true) };
});
app.post("/api/expenses/:id/status", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = statusUpdateSchema.parse(request.body);
const before = getExpense(database, id);
if (!before) notFound("账目不存在");
if (before.status === input.status) {
if (before.version !== input.version) conflict(database, id);
return { expense: publicExpense(database, before, true) };
}
const now = Date.now();
database.sqlite.transaction(() => {
const result = database.sqlite.prepare(`
UPDATE expenses SET status=?, version=version+1, updated_at=?, updated_by=?,
reimbursed_at=?, reimbursed_by=? WHERE id=? AND version=? AND deleted_at IS NULL
`).run(input.status, now, request.auth!.admin.id, input.status === "reimbursed" ? now : null, input.status === "reimbursed" ? request.auth!.admin.id : null, id, input.version);
if (result.changes !== 1) conflict(database, id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.status_changed",
targetType: "expense",
targetId: id,
before: { status: before.status, version: before.version },
after: { status: input.status, version: input.version + 1 },
});
})();
return { expense: publicExpense(database, getExpense(database, id)!, true) };
});
app.post("/api/expenses/:id/attachments", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const kind = attachmentKindSchema.parse((request.query as { kind?: string }).kind);
if (!request.isMultipart()) throw new AppError(415, "MULTIPART_REQUIRED", "附件必须使用文件表单提交");
const existing = getExpense(database, id);
if (!existing) notFound("账目不存在");
const fields: Record<string, string> = {};
const staged: StagedFile[] = [];
try {
for await (const part of request.parts()) {
if (part.type === "field") {
if (part.fieldname !== "version") throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段");
if (part.fieldname in fields) throw new AppError(400, "DUPLICATE_FIELD", "表单字段不能重复");
fields[part.fieldname] = String(part.value);
} else {
const expectedField = kind === "payment_proof" ? "paymentProofs" : "invoices";
if (part.fieldname !== expectedField) throw new AppError(400, "UNKNOWN_FILE_FIELD", "附件字段与类型不匹配");
if (staged.length >= config.maxFilesPerRequest) throw new AppError(413, "TOO_MANY_FILES", "单次上传文件数量超过限制");
staged.push(await stageMultipartFile(config, part, kind));
}
}
} catch (error) {
await discardStaged(staged);
throw error;
}
let version: number;
try {
version = z.coerce.number().int().positive().parse(fields.version);
} catch (error) {
await discardStaged(staged);
throw error;
}
if (staged.length < 1) throw new AppError(400, "FILE_REQUIRED", "请选择至少一个附件");
const promoted = await promoteAll(config, staged, database.sqlite);
const now = Date.now();
try {
database.sqlite.transaction(() => {
const current = getExpense(database, id);
if (!current) notFound("账目不存在");
if (current.version !== version) conflict(database, id);
const nextInvoiceMissingReason = kind === "invoice" ? null : normalizeInvoiceMissingReason(current.invoiceMissingReason);
const nextInvoiceCount = Number(current.invoiceCount) + (kind === "invoice" ? promoted.length : 0);
assertInvoiceCoverage(nextInvoiceCount, nextInvoiceMissingReason);
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 + promoted.reduce((sum, file) => sum + file.sizeBytes, 0) > config.maxRecordBytes) {
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 + promoted.reduce((sum, file) => sum + file.sizeBytes, 0) > config.maxTotalBytes) {
throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据");
}
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, id, 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, 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.attachments_added",
targetType: "expense",
targetId: id,
before: {
invoiceMissingReason: current.invoiceMissingReason,
invoiceCount: Number(current.invoiceCount),
version: current.version,
},
after: {
kind,
invoiceMissingReason: nextInvoiceMissingReason,
invoiceCount: nextInvoiceCount,
version: 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) };
});
app.delete("/api/attachments/:id", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = attachmentDeleteSchema.parse(request.body);
const attachment = database.sqlite.prepare(`
SELECT id, expense_id AS expenseId, kind, storage_path AS storagePath,
original_name AS originalName, mime_type AS mimeType, size_bytes AS sizeBytes,
sha256, created_at AS createdAt FROM attachments WHERE id=?
`).get(id) as AttachmentRow | undefined;
if (!attachment) notFound("附件不存在");
database.sqlite.transaction(() => {
const expense = getExpense(database, attachment.expenseId);
if (!expense) notFound("账目不存在");
if (expense.version !== input.version) conflict(database, expense.id);
if (attachment.kind === "payment_proof") {
const count = database.sqlite.prepare("SELECT COUNT(*) AS count FROM attachments WHERE expense_id=? AND kind='payment_proof'").get(expense.id) as { count: number };
if (count.count <= 1) throw new AppError(409, "LAST_PAYMENT_PROOF", "必须先上传替代凭证,才能删除最后一张付款凭证");
}
const requestedReason = input.invoiceMissingReason === undefined
? undefined
: normalizeInvoiceMissingReason(input.invoiceMissingReason);
const remainingInvoiceCount = attachment.kind === "invoice"
? Number((database.sqlite.prepare("SELECT COUNT(*) AS count FROM attachments WHERE expense_id=? AND kind='invoice' AND id<>?").get(expense.id, id) as { count: number }).count)
: Number(expense.invoiceCount);
const nextInvoiceMissingReason = requestedReason === undefined
? normalizeInvoiceMissingReason(expense.invoiceMissingReason)
: requestedReason;
assertInvoiceCoverage(remainingInvoiceCount, nextInvoiceMissingReason, 409);
const deleted = database.sqlite.prepare("DELETE FROM attachments WHERE id=? AND expense_id=?").run(id, expense.id);
if (deleted.changes !== 1) notFound("附件不存在");
const now = Date.now();
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.sqlite, attachment.storagePath, "attachment_deleted");
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.attachment_deleted",
targetType: "expense",
targetId: expense.id,
before: {
id: attachment.id,
kind: attachment.kind,
name: attachment.originalName,
sha256: attachment.sha256,
invoiceMissingReason: expense.invoiceMissingReason,
invoiceCount: Number(expense.invoiceCount),
version: expense.version,
},
after: {
invoiceMissingReason: nextInvoiceMissingReason,
invoiceCount: remainingInvoiceCount,
version: input.version + 1,
},
});
})();
// Finish the queued byte-deletion attempt before responding. Missing
// bytes are treated as already gone; retryable filesystem failures remain
// visible in the deletion queue for the janitor.
await processFileDeletions(database.sqlite, config);
return { expense: publicExpense(database, getExpense(database, attachment.expenseId)!, true) };
});
app.get("/api/attachments/:id/content", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const attachment = database.sqlite.prepare(`
SELECT a.id, a.expense_id AS expenseId, a.kind, a.storage_path AS storagePath,
a.original_name AS originalName, a.mime_type AS mimeType, a.size_bytes AS sizeBytes,
a.sha256, a.created_at AS createdAt FROM attachments a JOIN expenses e ON e.id=a.expense_id
WHERE a.id=? AND e.deleted_at IS NULL
`).get(id) as AttachmentRow | undefined;
if (!attachment) notFound("附件不存在");
let stream: Awaited<ReturnType<typeof fileReadStream>>;
try {
stream = await fileReadStream(config, attachment.storagePath);
} catch (error) {
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.attachment_read",
targetType: "expense",
targetId: attachment.expenseId,
outcome: "failure",
metadata: { attachmentId: attachment.id, mode: "unavailable" },
});
throw error;
}
const download = (request.query as { download?: string }).download === "1";
const inline = !download && (attachment.mimeType.startsWith("image/") || attachment.mimeType === "application/pdf");
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: download ? "expense.attachment_downloaded" : "expense.attachment_previewed",
targetType: "expense",
targetId: attachment.expenseId,
metadata: { attachmentId: attachment.id, kind: attachment.kind, sizeBytes: attachment.sizeBytes },
});
reply.header("Content-Type", attachment.mimeType);
reply.header("Content-Length", attachment.sizeBytes);
reply.header("X-Content-Type-Options", "nosniff");
reply.header("Cache-Control", "private, no-store");
if (inline) {
// PDF previews are rendered in the authenticated same-origin dialog;
// keep downloads unframeable while allowing this narrow preview case.
reply.header("Content-Security-Policy", "sandbox");
reply.header("X-Frame-Options", "SAMEORIGIN");
}
reply.header("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`);
return reply.send(stream);
});
app.delete("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = versionSchema.parse(request.body);
const before = getExpense(database, id);
if (!before) notFound("账目不存在");
const now = Date.now();
database.sqlite.transaction(() => {
const result = database.sqlite.prepare(`
UPDATE expenses SET deleted_at=?, deleted_by=?, version=version+1,
updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL
`).run(now, request.auth!.admin.id, now, request.auth!.admin.id, id, input.version);
if (result.changes !== 1) conflict(database, id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.trashed",
targetType: "expense",
targetId: id,
before: { status: before.status, version: before.version },
after: { deletedAt: now, version: input.version + 1 },
});
})();
return { expense: publicExpense(database, getExpense(database, id, true)!, true) };
});
app.get("/api/trash", { preHandler: guard(database, config) }, async () => {
const rows = database.sqlite.prepare(`
SELECT ${expenseBaseSelect()}
FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id
JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by
WHERE e.deleted_at IS NOT NULL GROUP BY e.id ORDER BY e.deleted_at DESC
`).all() as ExpenseRow[];
return { items: rows.map((row) => publicExpense(database, row)) };
});
app.post("/api/trash/:id/restore", { preHandler: guard(database, config) }, async (request) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = versionSchema.parse(request.body);
const existing = getExpense(database, id, true);
if (!existing || !existing.deletedAt) notFound("回收站中没有该账目");
const now = Date.now();
database.sqlite.transaction(() => {
const result = database.sqlite.prepare(`
UPDATE expenses SET deleted_at=NULL, deleted_by=NULL, version=version+1,
updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NOT NULL
`).run(now, request.auth!.admin.id, id, input.version);
if (result.changes !== 1) conflict(database, id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.restored",
targetType: "expense",
targetId: id,
before: { deletedAt: existing.deletedAt, version: existing.version },
after: { deletedAt: null, version: input.version + 1 },
});
})();
return { expense: publicExpense(database, getExpense(database, id)!, true) };
});
app.delete("/api/trash/:id", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
const input = permanentDeleteSchema.parse(request.body);
assertReauthAllowed(database, request, request.auth!.admin.id);
if (!await verifyPassword(request.auth!.admin.passwordHash, input.password)) {
recordReauthFailure(database, request, request.auth!.admin.id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.purge",
targetType: "expense",
targetId: id,
outcome: "denied",
});
throw new AppError(401, "CURRENT_PASSWORD_INVALID", "密码确认失败");
}
clearReauthFailures(database, request, request.auth!.admin.id);
const existing = getExpense(database, id, true);
if (!existing || !existing.deletedAt) notFound("回收站中没有该账目");
const attachmentRows = listAttachments(database, id);
const exportFiles: string[] = [];
database.sqlite.transaction(() => {
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.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;
if (!snapshot.expenses.some((expense) => expense.id === id)) continue;
database.sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(job.id);
if (job.filePath) exportFiles.push(job.filePath);
}
database.sqlite.prepare("DELETE FROM expenses WHERE id=?").run(id);
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "expense.purged",
targetType: "expense",
targetId: id,
before: {
paidAt: existing.paidAt,
amountCents: existing.amountCents,
note: existing.note,
invoiceMissingReason: existing.invoiceMissingReason,
status: existing.status,
deletedAt: existing.deletedAt,
attachments: attachmentRows.map((item) => ({ kind: item.kind, name: item.originalName, size: item.sizeBytes, sha256: item.sha256 })),
},
after: { permanentlyDeleted: true },
});
}).immediate();
await Promise.all(exportFiles.map((file) => unlink(safeStoragePath(config.exportsDir, file)).catch(() => undefined)));
await processFileDeletions(database.sqlite, config);
return reply.code(204).send();
});
app.get("/api/audit", { preHandler: guard(database, config) }, async (request) => {
const query = z.object({
actorId: z.string().uuid().optional(),
action: z.string().max(100).optional(),
targetType: z.string().max(50).optional(),
targetId: z.string().max(100).optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
offset: z.coerce.number().int().min(0).max(100_000).default(0),
}).strict().parse(request.query);
const clauses: string[] = [];
const values: unknown[] = [];
if (query.actorId) { clauses.push("actor_admin_id=?"); values.push(query.actorId); }
if (query.action) { clauses.push("action=?"); values.push(query.action); }
if (query.targetType) { clauses.push("target_type=?"); values.push(query.targetType); }
if (query.targetId) { clauses.push("target_id=?"); values.push(query.targetId); }
values.push(query.limit, query.offset);
const rows = database.sqlite.prepare(`
SELECT id, occurred_at AS occurredAt, request_id AS requestId,
actor_admin_id AS actorAdminId, actor_username AS actorUsername,
action, target_type AS targetType, target_id AS targetId, outcome,
before_json AS beforeJson, after_json AS afterJson, metadata_json AS metadataJson
FROM audit_events ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
ORDER BY occurred_at DESC, id DESC LIMIT ? OFFSET ?
`).all(...values);
return { items: rows };
});
app.post("/api/exports", { preHandler: guard(database, config) }, async (request, reply) => {
const selection = exportRequestSchema.parse(request.body);
let rows: ExpenseRow[];
if ("ids" in selection) {
const placeholders = selection.ids.map(() => "?").join(",");
rows = database.sqlite.prepare(`
SELECT ${expenseBaseSelect()}
FROM expenses e LEFT JOIN attachments a ON a.expense_id=e.id
JOIN admins creator ON creator.id=e.created_by JOIN admins updater ON updater.id=e.updated_by
WHERE e.deleted_at IS NULL AND e.id IN (${placeholders}) GROUP BY e.id ORDER BY e.paid_at DESC, e.id DESC
`).all(...selection.ids) as ExpenseRow[];
if (rows.length !== new Set(selection.ids).size) throw new AppError(404, "EXPORT_RECORD_NOT_FOUND", "部分勾选记录不存在或已进入回收站");
} else {
rows = filteredExpenses(database, config, { ...selection, missingInvoice: selection.missingInvoice });
}
if (rows.length === 0) throw new AppError(400, "EMPTY_EXPORT", "没有可导出的记录");
if (rows.length > config.maxExportRecords) throw new AppError(413, "EXPORT_TOO_LARGE", "导出记录数超过限制");
const snapshot: ExportSnapshot = {
expenses: rows.map((row): ExportExpense => ({
id: row.id,
paidAt: row.paidAt,
amountCents: row.amountCents,
note: row.note,
invoiceMissingReason: row.invoiceMissingReason,
status: row.status,
attachments: listAttachments(database, row.id),
})),
includeManifest: selection.includeManifest,
};
const snapshotBytes = snapshot.expenses.reduce((sum, expense) => sum + expense.attachments.reduce((attachmentSum, attachment) => attachmentSum + attachment.sizeBytes, 0), 0);
if (snapshotBytes > config.maxExportBytes) throw new AppError(413, "EXPORT_TOO_LARGE", "导出附件总大小超过限制");
const jobId = database.sqlite.transaction(() => {
const activeJobs = database.sqlite.prepare("SELECT COUNT(*) AS count FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").get(Date.now()) as { count: number };
if (activeJobs.count >= config.maxConcurrentExports) throw new AppError(429, "EXPORT_BUSY", "当前导出任务较多,请稍后再试");
const storedBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM export_jobs WHERE status='ready' AND expires_at > ?").get(Date.now()) as { total: number }).total;
if (storedBytes + snapshotBytes > config.maxExportStorageBytes) {
throw new AppError(413, "EXPORT_STORAGE_LIMIT", "导出缓存空间已满,请先下载或等待旧导出过期");
}
const id = insertExportJob(database.sqlite, config, {
adminId: request.auth!.admin.id,
sessionHash: request.auth!.tokenHash,
selection,
snapshot,
});
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "export.created",
targetType: "export",
targetId: id,
metadata: { expenseIds: rows.map((row) => row.id), count: rows.length, amountCents: rows.reduce((sum, row) => sum + row.amountCents, 0), includeManifest: selection.includeManifest },
});
return id;
}).immediate();
void buildExportJob(database.sqlite, config, jobId);
return reply.code(202).send({ job: { id: jobId, status: "queued" } });
});
app.get("/api/exports/:id", { preHandler: guard(database, config) }, async (request) => {
await expireExports(database.sqlite, config);
const id = z.string().uuid().parse((request.params as { id: string }).id);
const job = database.sqlite.prepare(`
SELECT id, status, file_name AS fileName, size_bytes AS sizeBytes,
error_message AS errorMessage, created_at AS createdAt, ready_at AS readyAt,
expires_at AS expiresAt FROM export_jobs WHERE id=? AND session_hash=?
`).get(id, request.auth!.tokenHash);
if (!job) notFound("导出任务不存在");
return { job };
});
app.get("/api/exports/:id/download", { preHandler: guard(database, config) }, async (request, reply) => {
await expireExports(database.sqlite, config);
const id = z.string().uuid().parse((request.params as { id: string }).id);
const job = database.sqlite.prepare(`
SELECT status, file_path AS filePath, file_name AS fileName, size_bytes AS sizeBytes
FROM export_jobs WHERE id=? AND session_hash=?
`).get(id, request.auth!.tokenHash) as { status: string; filePath: string | null; fileName: string; sizeBytes: number | null } | undefined;
if (!job) notFound("导出任务不存在");
if (job.status !== "ready" || !job.filePath) throw new AppError(409, "EXPORT_NOT_READY", "导出文件尚未生成完成");
writeAudit(database.sqlite, {
requestId: request.id,
actorAdminId: request.auth!.admin.id,
actorUsername: request.auth!.admin.username,
action: "export.downloaded",
targetType: "export",
targetId: id,
metadata: { sizeBytes: job.sizeBytes ?? null },
});
reply.header("Content-Type", "application/zip");
if (job.sizeBytes) reply.header("Content-Length", job.sizeBytes);
reply.header("Cache-Control", "private, no-store");
reply.header("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(job.fileName)}`);
return reply.send(await safeReadStream(config.exportsDir, job.filePath));
});
const hasWeb = existsSync(config.webDir);
if (hasWeb) {
// 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).
app.setNotFoundHandler((request, reply) => {
if (request.url.split("?", 1)[0]!.startsWith("/api/")) {
return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在")));
}
if (hasWeb) return reply.sendFile("index.html");
return reply.code(404).send({
message: `Route ${request.method}:${request.url} not found`,
error: "Not Found",
statusCode: 404,
});
});
if (!hasWeb) {
app.get("/", async () => ({ name: "TallyNote", mode: "api", hint: "开发界面运行在 Vite 端口" }));
}
return app;
}