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
+46
View File
@@ -0,0 +1,46 @@
import Database from "better-sqlite3";
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { readdirSync, readFileSync } from "node:fs";
import { chmodSync, existsSync } from "node:fs";
import path from "node:path";
import type { AppConfig } from "../config.js";
import * as schema from "./schema.js";
export type DatabaseContext = {
sqlite: Database.Database;
db: BetterSQLite3Database<typeof schema>;
};
function migrate(sqlite: Database.Database, migrationsDir: string): void {
sqlite.exec("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT");
const applied = new Set(
(sqlite.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((row) => row.name),
);
const files = readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")).sort();
const apply = sqlite.transaction((name: string, sqlText: string) => {
sqlite.exec(sqlText);
sqlite.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES (?, ?)").run(name, Date.now());
});
for (const name of files) {
if (!applied.has(name)) apply(name, readFileSync(path.join(migrationsDir, name), "utf8"));
}
}
export function openDatabase(config: AppConfig): DatabaseContext {
const sqlite = new Database(config.dbPath);
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = FULL");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("temp_store = MEMORY");
migrate(sqlite, config.migrationsDir);
// SQLite creates the database and journal files after the initial directory
// preparation. Enforce private permissions again after opening so a broad
// process umask can never expose financial data to other local users.
for (const filePath of [config.dbPath, `${config.dbPath}-wal`, `${config.dbPath}-shm`]) {
if (existsSync(filePath)) chmodSync(filePath, 0o600);
}
const foreignKeys = sqlite.pragma("foreign_keys", { simple: true });
if (foreignKeys !== 1) throw new Error("SQLite 外键未启用");
return { sqlite, db: drizzle(sqlite, { schema }) };
}
+160
View File
@@ -0,0 +1,160 @@
import { sql } from "drizzle-orm";
import { blob, check, index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const admins = sqliteTable("admins", {
id: text("id").primaryKey(),
username: text("username").notNull(),
usernameNorm: text("username_norm").notNull(),
displayName: text("display_name").notNull(),
passwordHash: text("password_hash").notNull(),
status: text("status", { enum: ["active", "disabled"] }).notNull().default("active"),
mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true),
authVersion: integer("auth_version").notNull().default(1),
version: integer("version").notNull().default(1),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by"),
passwordChangedAt: integer("password_changed_at"),
lastLoginAt: integer("last_login_at"),
disabledAt: integer("disabled_at"),
disabledBy: text("disabled_by"),
}, (table) => [
uniqueIndex("admins_username_norm_uq").on(table.usernameNorm),
check("admins_status_ck", sql`${table.status} in ('active','disabled')`),
check("admins_versions_ck", sql`${table.version} >= 1 and ${table.authVersion} >= 1`),
]);
export const sessions = sqliteTable("sessions", {
tokenHash: text("token_hash").primaryKey(),
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
csrfHash: text("csrf_hash").notNull(),
authVersion: integer("auth_version").notNull(),
createdAt: integer("created_at").notNull(),
lastSeenAt: integer("last_seen_at").notNull(),
idleExpiresAt: integer("idle_expires_at").notNull(),
absoluteExpiresAt: integer("absolute_expires_at").notNull(),
}, (table) => [index("sessions_admin_idx").on(table.adminId), index("sessions_expiry_idx").on(table.idleExpiresAt)]);
export const expenses = sqliteTable("expenses", {
id: text("id").primaryKey(),
paidAt: integer("paid_at").notNull(),
amountCents: integer("amount_cents").notNull(),
note: text("note").notNull().default(""),
invoiceMissingReason: text("invoice_missing_reason"),
status: text("status", { enum: ["unreimbursed", "reimbursed"] }).notNull().default("unreimbursed"),
version: integer("version").notNull().default(1),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
updatedAt: integer("updated_at").notNull(),
updatedBy: text("updated_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
reimbursedAt: integer("reimbursed_at"),
reimbursedBy: text("reimbursed_by").references(() => admins.id, { onDelete: "restrict" }),
deletedAt: integer("deleted_at"),
deletedBy: text("deleted_by").references(() => admins.id, { onDelete: "restrict" }),
}, (table) => [
index("expenses_list_idx").on(table.deletedAt, table.status, table.paidAt),
check("expenses_amount_ck", sql`${table.amountCents} > 0 and ${table.amountCents} <= 999999999999`),
check("expenses_status_ck", sql`${table.status} in ('unreimbursed','reimbursed')`),
check("expenses_version_ck", sql`${table.version} >= 1`),
]);
export const attachments = sqliteTable("attachments", {
id: text("id").primaryKey(),
expenseId: text("expense_id").notNull().references(() => expenses.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["payment_proof", "invoice"] }).notNull(),
storagePath: text("storage_path").notNull(),
originalName: text("original_name").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
sha256: text("sha256").notNull(),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
}, (table) => [
uniqueIndex("attachments_path_uq").on(table.storagePath),
index("attachments_expense_idx").on(table.expenseId),
check("attachments_kind_ck", sql`${table.kind} in ('payment_proof','invoice')`),
check("attachments_size_ck", sql`${table.sizeBytes} > 0`),
]);
export const auditEvents = sqliteTable("audit_events", {
id: integer("id").primaryKey({ autoIncrement: true }),
occurredAt: integer("occurred_at").notNull(),
requestId: text("request_id").notNull(),
actorAdminId: text("actor_admin_id"),
actorUsername: text("actor_username"),
action: text("action").notNull(),
targetType: text("target_type").notNull(),
targetId: text("target_id"),
outcome: text("outcome", { enum: ["success", "denied", "failure"] }).notNull(),
beforeJson: text("before_json"),
afterJson: text("after_json"),
metadataJson: text("metadata_json"),
}, (table) => [index("audit_time_idx").on(table.occurredAt), index("audit_target_idx").on(table.targetType, table.targetId)]);
export const systemSettings = sqliteTable("system_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const exportJobs = sqliteTable("export_jobs", {
id: text("id").primaryKey(),
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
sessionHash: text("session_hash").notNull(),
status: text("status", { enum: ["queued", "building", "ready", "failed", "expired"] }).notNull(),
selectionJson: text("selection_json").notNull(),
snapshotJson: text("snapshot_json").notNull(),
filePath: text("file_path"),
fileName: text("file_name").notNull(),
sizeBytes: integer("size_bytes"),
sha256: text("sha256"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
readyAt: integer("ready_at"),
expiresAt: integer("expires_at").notNull(),
}, (table) => [index("exports_expiry_idx").on(table.expiresAt), index("exports_session_idx").on(table.sessionHash)]);
export const loginAttempts = sqliteTable("login_attempts", {
keyHash: text("key_hash").primaryKey(),
windowStart: integer("window_start").notNull(),
failures: integer("failures").notNull(),
blockedUntil: integer("blocked_until"),
});
export const fileDeletions = sqliteTable("file_deletions", {
id: text("id").primaryKey(),
storagePath: text("storage_path").notNull(),
reason: text("reason").notNull(),
status: text("status", { enum: ["pending", "complete", "failed"] }).notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
lastError: text("last_error"),
createdAt: integer("created_at").notNull(),
completedAt: integer("completed_at"),
}, (table) => [index("file_deletions_status_idx").on(table.status)]);
export const updateJobs = sqliteTable("update_jobs", {
id: text("id").primaryKey(),
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
sessionHash: text("session_hash"),
requestId: text("request_id"),
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
version: text("version").notNull(),
platform: text("platform").notNull(),
releaseUrl: text("release_url"),
assetName: text("asset_name"),
assetUrl: text("asset_url").notNull(),
expectedSha256: text("expected_sha256"),
actualSha256: text("actual_sha256"),
downloadPath: text("download_path"),
backupPath: text("backup_path"),
sizeBytes: integer("size_bytes"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
requestedAt: integer("requested_at"),
startedAt: integer("started_at"),
updatedAt: integer("updated_at").notNull(),
completedAt: integer("completed_at"),
}, (table) => [
index("update_jobs_status_idx").on(table.status, table.createdAt),
index("update_jobs_admin_idx").on(table.adminId, table.createdAt),
index("update_jobs_session_idx").on(table.sessionHash),
]);