// 同步服务存储层:SQLite(node:sqlite 内置,零依赖) import { DatabaseSync } from 'node:sqlite'; import fs from 'node:fs'; import path from 'node:path'; export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data'); export const BLOB_DIR = path.join(DATA_DIR, 'blobs'); const DB_PATH = path.join(DATA_DIR, 'sync.db'); let db = null; export function getDb() { if (db) return db; fs.mkdirSync(BLOB_DIR, { recursive: true }); db = new DatabaseSync(DB_PATH); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA foreign_keys = ON;'); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, salt TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, last_seen_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), UNIQUE(user_id, name) ); CREATE TABLE IF NOT EXISTS tokens ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), device_id TEXT NOT NULL REFERENCES devices(id), created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), expires_at TEXT NOT NULL ); -- 每行最新状态(按 user + table + row_uuid 唯一,LWW 胜者) CREATE TABLE IF NOT EXISTS current ( user_id TEXT NOT NULL, table_name TEXT NOT NULL, row_uuid TEXT NOT NULL, op TEXT NOT NULL, hlc TEXT NOT NULL, ms INTEGER NOT NULL, cnt INTEGER NOT NULL, dev TEXT NOT NULL, data TEXT, updated_seq INTEGER, PRIMARY KEY (user_id, table_name, row_uuid) ); -- 全量变更历史(seq 单调递增,作为拉取游标基准) CREATE TABLE IF NOT EXISTS changes ( seq INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, table_name TEXT NOT NULL, row_uuid TEXT NOT NULL, op TEXT NOT NULL, hlc TEXT NOT NULL, data TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) ); CREATE INDEX IF NOT EXISTS idx_current_seq ON current(user_id, updated_seq); CREATE INDEX IF NOT EXISTS idx_changes_user ON changes(user_id, seq); -- 附件:内容寻址去重 CREATE TABLE IF NOT EXISTS attachments ( user_id TEXT NOT NULL, sha256 TEXT NOT NULL, size INTEGER NOT NULL, mime TEXT, file_name TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), PRIMARY KEY (user_id, sha256) ); `); // 管理员与邀请码(动态颁发制;首个注册用户自动成为管理员) const hasAdmin = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('users') WHERE name='is_admin'").get().c; if (Number(hasAdmin) === 0) db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;"); db.exec(`CREATE TABLE IF NOT EXISTS invite_codes ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, created_by TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), used_by TEXT, used_at TEXT, valid INTEGER NOT NULL DEFAULT 1 );`); const hasRemark = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='remark'").get().c; if (Number(hasRemark) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN remark TEXT;"); return db; } // 允许同步的表(与客户端 schema 对应;footprint_dimension 以 uuid 对作普通行同步) export const SYNC_TABLES = new Set([ 'footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment', 'semester', 'course_grade', 'metric_def', 'saved_view', 'footprint_dimension' ]);