import type Database from "better-sqlite3"; // Version 4 matches the schema already deployed on us3. Never reuse its number. function ensureCustomNodeColumns(db: Database.Database) { const columns = new Set( (db.pragma("table_info(hy2_nodes)") as { name: string }[]).map( (column) => column.name, ), ); if (!columns.has("node_type")) db.exec( "ALTER TABLE hy2_nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'hy2'", ); if (!columns.has("custom_config_yaml")) db.exec( "ALTER TABLE hy2_nodes ADD COLUMN custom_config_yaml TEXT NOT NULL DEFAULT ''", ); } const MIGRATIONS: (string | ((db: Database.Database) => void))[] = [ ` CREATE TABLE IF NOT EXISTS admins ( id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS admin_sessions ( id TEXT PRIMARY KEY, admin_id INTEGER NOT NULL, session_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS hy2_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, auth_id TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, expires_at TEXT, traffic_limit_bytes INTEGER, used_tx_bytes INTEGER NOT NULL DEFAULT 0, used_rx_bytes INTEGER NOT NULL DEFAULT 0, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS hy2_nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, auth_token TEXT NOT NULL, traffic_stats_url TEXT NOT NULL, traffic_stats_secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, poll_interval_seconds INTEGER NOT NULL DEFAULT 15, last_polled_at TEXT, last_sync_ok_at TEXT, last_error_at TEXT, last_error_message TEXT, last_online_users INTEGER NOT NULL DEFAULT 0, last_stream_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS traffic_ledgers ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER NOT NULL, user_id INTEGER, auth_id TEXT NOT NULL, tx_bytes INTEGER NOT NULL, rx_bytes INTEGER NOT NULL, window_started_at TEXT NOT NULL, window_ended_at TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS node_sync_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT NOT NULL, ok INTEGER NOT NULL, traffic_entries INTEGER NOT NULL DEFAULT 0, online_users INTEGER NOT NULL DEFAULT 0, stream_count INTEGER NOT NULL DEFAULT 0, kicked_auth_ids TEXT NOT NULL DEFAULT '', error_message TEXT, created_at TEXT NOT NULL, FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS auth_audits ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER, user_id INTEGER, auth_id TEXT, username TEXT, addr TEXT, requested_tx INTEGER, ok INTEGER NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS node_user_presence ( node_id INTEGER NOT NULL, user_id INTEGER, auth_id TEXT NOT NULL, connections INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY (node_id, auth_id), FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_hy2_users_auth_id ON hy2_users(auth_id); CREATE INDEX IF NOT EXISTS idx_hy2_users_username ON hy2_users(username); CREATE INDEX IF NOT EXISTS idx_hy2_nodes_enabled ON hy2_nodes(enabled); CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id); CREATE INDEX IF NOT EXISTS idx_traffic_ledgers_created_at ON traffic_ledgers(created_at); CREATE INDEX IF NOT EXISTS idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_auth_audits_created_at ON auth_audits(created_at DESC); `, ` ALTER TABLE hy2_users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0; ALTER TABLE hy2_users ADD COLUMN auth_secret_encrypted TEXT; ALTER TABLE hy2_users ADD COLUMN subscription_token TEXT; ALTER TABLE hy2_users ADD COLUMN subscription_label TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, session_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); ALTER TABLE hy2_nodes ADD COLUMN server_host TEXT NOT NULL DEFAULT ''; ALTER TABLE hy2_nodes ADD COLUMN server_port INTEGER NOT NULL DEFAULT 443; ALTER TABLE hy2_nodes ADD COLUMN client_name TEXT NOT NULL DEFAULT ''; ALTER TABLE hy2_nodes ADD COLUMN bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30; ALTER TABLE hy2_nodes ADD COLUMN bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30; ALTER TABLE hy2_nodes ADD COLUMN skip_cert_verify INTEGER NOT NULL DEFAULT 0; ALTER TABLE hy2_nodes ADD COLUMN sni TEXT; ALTER TABLE hy2_nodes ADD COLUMN traffic_stats_listen TEXT NOT NULL DEFAULT ':9999'; CREATE UNIQUE INDEX IF NOT EXISTS idx_hy2_users_subscription_token ON hy2_users(subscription_token); CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); `, ` ALTER TABLE hy2_nodes ADD COLUMN hy2_listen TEXT NOT NULL DEFAULT ':443'; `, ensureCustomNodeColumns, ]; export function migrateDatabase(db: Database.Database) { // Read the version under the same write lock as the migration to avoid races. db.transaction(() => { const currentVersion = db.pragma("user_version", { simple: true, }) as number; if (currentVersion > MIGRATIONS.length) throw new Error("数据库版本高于当前程序,请使用匹配的程序版本。"); for (let index = currentVersion; index < MIGRATIONS.length; index += 1) { const migration = MIGRATIONS[index]; if (typeof migration === "string") db.exec(migration); else migration(db); db.pragma(`user_version = ${index + 1}`); } // Also repairs a partially applied v4 without rewriting any existing data. ensureCustomNodeColumns(db); }).immediate(); }