// Package store 提供本项目唯一持久层:SQLite(modernc.org/sqlite 纯 Go,无 CGO)。 // // 设计要点(design-arch §4.2 / Contract A1-store): // - WAL + busy_timeout=2s + foreign_keys=on; // - 单写者原则:scheduler 独占写,gateway/worker 只经注入的 repository 读; // - 全部 SQL 用 `?` 占位符、显式列名(禁 SELECT *)、行级错误处理。 package store import ( "database/sql" "fmt" ) // DB 包装 *sql.DB,各 repository 共享。 type DB struct { raw *sql.DB } // Open 打开 SQLite 并设置连接参数(WAL/busy_timeout/foreign_keys)。 func Open(path string) (*DB, error) { dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2000)&_pragma=foreign_keys(1)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("store: 打开 %s: %w", path, err) } // SQLite 单写者:限制连接数避免 SQLITE_BUSY 风暴。 db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("store: ping %s: %w", path, err) } return &DB{raw: db}, nil } // Close 关闭底层连接。 func (d *DB) Close() error { return d.raw.Close() } // Raw 暴露底层 *sql.DB(仅限测试与 backup sidecar 场景)。 func (d *DB) Raw() *sql.DB { return d.raw } // schemaV1 migration v1 全量 DDL(Contract A1:consumers/api_keys/quota_ledger/ // jobs/dead_letters/audit/rules 七表)。 var schemaV1 = []string{ `CREATE TABLE IF NOT EXISTS consumers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, contact TEXT, status TEXT NOT NULL DEFAULT 'enabled', created_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, consumer_id INTEGER NOT NULL REFERENCES consumers(id), prefix TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', hash TEXT NOT NULL UNIQUE, salt TEXT NOT NULL, scopes TEXT NOT NULL DEFAULT 'search,read', rpm INTEGER NOT NULL DEFAULT 60, daily_quota INTEGER NOT NULL DEFAULT 1000, monthly_quota INTEGER NOT NULL DEFAULT 20000, concurrent_sessions INTEGER NOT NULL DEFAULT 2, status TEXT NOT NULL DEFAULT 'enabled', expires_at TEXT, created_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS quota_ledger ( key_id INTEGER NOT NULL REFERENCES api_keys(id), window TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0, reserved INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (key_id, window) )`, `CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL UNIQUE, intent TEXT NOT NULL, payload TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100, status TEXT NOT NULL DEFAULT 'queued', available_at TEXT NOT NULL, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, worker TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_jobs_claim ON jobs(status, priority, available_at)`, `CREATE TABLE IF NOT EXISTS dead_letters ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, request_id TEXT NOT NULL, intent TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER NOT NULL, last_error TEXT, dead_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, consumer_id INTEGER, url TEXT NOT NULL, rule_id TEXT NOT NULL, ts TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts)`, `CREATE TABLE IF NOT EXISTS rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_type TEXT NOT NULL, value TEXT NOT NULL, action TEXT NOT NULL, sort INTEGER NOT NULL DEFAULT 0, UNIQUE(match_type, value) )`, `CREATE INDEX IF NOT EXISTS idx_rules_sort ON rules(sort)`, `CREATE TABLE IF NOT EXISTS robots_cache ( host TEXT PRIMARY KEY, body TEXT NOT NULL, fetched_at TEXT NOT NULL, expires_at TEXT NOT NULL )`, } // Migrate 执行 migration;已存在的表不重复建(幂等)。 func (d *DB) Migrate() error { for _, stmt := range schemaV1 { if _, err := d.raw.Exec(stmt); err != nil { return fmt.Errorf("store: migration: %w (stmt=%s)", err, firstLine(stmt)) } } return nil } func firstLine(s string) string { for i := 0; i < len(s); i++ { if s[i] == '\n' { return s[:i] } } return s }