单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
143 lines
4.2 KiB
Go
143 lines
4.2 KiB
Go
// 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
|
||
}
|