chore: 准备发布 v1.0.0 版本
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LegacyNode represents a node from the old file-based storage.
|
||||
type LegacyNode struct {
|
||||
Name string
|
||||
URI string
|
||||
Source string // inline, nodes_file, subscription, manual
|
||||
Port uint16
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// MigrateLegacyData imports data from old file-based storage into the Store.
|
||||
// It checks if the database already has nodes; if so, it skips migration.
|
||||
// After successful migration, old files are renamed with .bak suffix.
|
||||
//
|
||||
// Parameters:
|
||||
// - configDir: directory containing config.yaml and node files
|
||||
// - inlineNodes: nodes defined directly in config.yaml
|
||||
// - nodesFilePath: path to nodes.txt (subscription/file nodes)
|
||||
// - subscriptions: list of subscription URLs (to determine source type)
|
||||
func MigrateLegacyData(ctx context.Context, s Store, configDir string, inlineNodes []LegacyNode, nodesFilePath string, subscriptions []string) error {
|
||||
// Check if database already has nodes
|
||||
count, err := s.CountNodes(ctx, NodeFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("check existing nodes: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
log.Printf("[store] database already has %d nodes, skipping legacy migration", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[store] starting legacy data migration...")
|
||||
|
||||
var totalImported int
|
||||
|
||||
// 1. Import inline nodes from config.yaml
|
||||
if len(inlineNodes) > 0 {
|
||||
var nodes []Node
|
||||
for _, ln := range inlineNodes {
|
||||
name := ln.Name
|
||||
if name == "" {
|
||||
name = extractNameFromURI(ln.URI)
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("node-%d", len(nodes)+1)
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
URI: ln.URI,
|
||||
Name: name,
|
||||
Source: NodeSourceInline,
|
||||
Port: ln.Port,
|
||||
Username: ln.Username,
|
||||
Password: ln.Password,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||
return fmt.Errorf("import inline nodes: %w", err)
|
||||
}
|
||||
totalImported += len(nodes)
|
||||
log.Printf("[store] imported %d inline nodes from config.yaml", len(nodes))
|
||||
}
|
||||
|
||||
// 2. Import nodes from nodes.txt
|
||||
if nodesFilePath != "" {
|
||||
if _, statErr := os.Stat(nodesFilePath); statErr == nil {
|
||||
fileNodes, err := loadNodesFromLegacyFile(nodesFilePath)
|
||||
if err != nil {
|
||||
log.Printf("[store] ⚠️ failed to parse %s: %v (skipping)", nodesFilePath, err)
|
||||
} else if len(fileNodes) > 0 {
|
||||
// Determine source based on whether subscriptions are configured
|
||||
source := NodeSourceFile
|
||||
if len(subscriptions) > 0 {
|
||||
source = NodeSourceSubscription
|
||||
}
|
||||
|
||||
var nodes []Node
|
||||
for _, uri := range fileNodes {
|
||||
name := extractNameFromURI(uri)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("node-%d", totalImported+len(nodes)+1)
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
URI: uri,
|
||||
Name: name,
|
||||
Source: source,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||
return fmt.Errorf("import file nodes: %w", err)
|
||||
}
|
||||
totalImported += len(nodes)
|
||||
log.Printf("[store] imported %d nodes from %s (source: %s)", len(nodes), nodesFilePath, source)
|
||||
|
||||
// Rename old file
|
||||
backupPath := nodesFilePath + ".bak"
|
||||
if err := os.Rename(nodesFilePath, backupPath); err != nil {
|
||||
log.Printf("[store] ⚠️ failed to rename %s to %s: %v", nodesFilePath, backupPath, err)
|
||||
} else {
|
||||
log.Printf("[store] renamed %s -> %s", nodesFilePath, backupPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Import manual nodes from manual_nodes.txt
|
||||
manualPath := filepath.Join(configDir, "manual_nodes.txt")
|
||||
if _, statErr := os.Stat(manualPath); statErr == nil {
|
||||
manualURIs, err := loadNodesFromLegacyFile(manualPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] ⚠️ failed to parse %s: %v (skipping)", manualPath, err)
|
||||
} else if len(manualURIs) > 0 {
|
||||
var nodes []Node
|
||||
for _, uri := range manualURIs {
|
||||
name := extractNameFromURI(uri)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("manual-%d", len(nodes)+1)
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
URI: uri,
|
||||
Name: name,
|
||||
Source: NodeSourceManual,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||
return fmt.Errorf("import manual nodes: %w", err)
|
||||
}
|
||||
totalImported += len(nodes)
|
||||
log.Printf("[store] imported %d manual nodes from %s", len(nodes), manualPath)
|
||||
|
||||
// Rename old file
|
||||
backupPath := manualPath + ".bak"
|
||||
if err := os.Rename(manualPath, backupPath); err != nil {
|
||||
log.Printf("[store] ⚠️ failed to rename %s to %s: %v", manualPath, backupPath, err)
|
||||
} else {
|
||||
log.Printf("[store] renamed %s -> %s", manualPath, backupPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalImported > 0 {
|
||||
log.Printf("[store] ✅ legacy migration completed: %d total nodes imported", totalImported)
|
||||
} else {
|
||||
log.Printf("[store] no legacy data found to migrate")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadNodesFromLegacyFile reads a file where each line is a proxy URI.
|
||||
// Comments (#) and empty lines are skipped.
|
||||
func loadNodesFromLegacyFile(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var uris []string
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if isProxyURI(line) {
|
||||
uris = append(uris, line)
|
||||
}
|
||||
}
|
||||
return uris, nil
|
||||
}
|
||||
|
||||
// isProxyURI checks if a string looks like a proxy URI.
|
||||
func isProxyURI(s string) bool {
|
||||
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
|
||||
"hysteria://", "hysteria2://", "hy2://", "http://", "socks5://"}
|
||||
lower := strings.ToLower(s)
|
||||
for _, scheme := range schemes {
|
||||
if strings.HasPrefix(lower, scheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractNameFromURI extracts a name from the URI fragment (#name).
|
||||
func extractNameFromURI(uri string) string {
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if parsed.Fragment == "" {
|
||||
return ""
|
||||
}
|
||||
decoded, err := url.QueryUnescape(parsed.Fragment)
|
||||
if err != nil {
|
||||
return parsed.Fragment
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Migration represents a single database schema migration.
|
||||
type Migration struct {
|
||||
Version int
|
||||
Description string
|
||||
Up string
|
||||
}
|
||||
|
||||
// allMigrations returns all migrations in order. New migrations should be
|
||||
// appended to this list with incrementing version numbers.
|
||||
func allMigrations() []Migration {
|
||||
return []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
Description: "initial schema",
|
||||
Up: `
|
||||
-- Nodes table: stores all proxy nodes
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uri TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(uri)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_region ON nodes(region);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_enabled ON nodes(enabled);
|
||||
|
||||
-- Node runtime statistics
|
||||
CREATE TABLE IF NOT EXISTS node_stats (
|
||||
node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
blacklisted INTEGER NOT NULL DEFAULT 0,
|
||||
blacklisted_until TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
last_failure_at TEXT NOT NULL DEFAULT '',
|
||||
last_success_at TEXT NOT NULL DEFAULT '',
|
||||
last_latency_ms INTEGER NOT NULL DEFAULT -1,
|
||||
available INTEGER NOT NULL DEFAULT 0,
|
||||
initial_check_done INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Node event timeline for debug tracking
|
||||
CREATE TABLE IF NOT EXISTS node_timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_node_id ON node_timeline(node_id);
|
||||
|
||||
-- User authentication sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- Subscription refresh status (singleton row)
|
||||
CREATE TABLE IF NOT EXISTS subscription_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_refresh TEXT NOT NULL DEFAULT '',
|
||||
next_refresh TEXT NOT NULL DEFAULT '',
|
||||
node_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
refresh_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_refreshing INTEGER NOT NULL DEFAULT 0,
|
||||
nodes_hash TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Insert the singleton subscription status row
|
||||
INSERT OR IGNORE INTO subscription_status (id) VALUES (1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 2,
|
||||
Description: "add traffic columns to node_stats",
|
||||
Up: `
|
||||
ALTER TABLE node_stats ADD COLUMN total_upload_bytes INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE node_stats ADD COLUMN total_download_bytes INTEGER NOT NULL DEFAULT 0;
|
||||
`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate applies all pending migrations to the database.
|
||||
// Each migration runs in its own transaction. Already-applied migrations
|
||||
// (tracked in schema_migrations) are skipped.
|
||||
func Migrate(db *sql.DB) error {
|
||||
// Create the migrations tracking table
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Get the current version
|
||||
var currentVersion int
|
||||
row := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations")
|
||||
if err := row.Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("query current version: %w", err)
|
||||
}
|
||||
|
||||
migrations := allMigrations()
|
||||
applied := 0
|
||||
|
||||
for _, m := range migrations {
|
||||
if m.Version <= currentVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[store] applying migration %d: %s", m.Version, m.Description)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for migration %d: %w", m.Version, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(m.Up); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("execute migration %d (%s): %w", m.Version, m.Description, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)",
|
||||
m.Version, time.Now().UTC().Format(time.RFC3339), m.Description,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", m.Version, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", m.Version, err)
|
||||
}
|
||||
|
||||
applied++
|
||||
log.Printf("[store] migration %d applied successfully", m.Version)
|
||||
}
|
||||
|
||||
if applied > 0 {
|
||||
log.Printf("[store] %d migration(s) applied, current version: %d", applied, migrations[len(migrations)-1].Version)
|
||||
} else {
|
||||
log.Printf("[store] database schema is up to date (version %d)", currentVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentVersion returns the current schema version.
|
||||
func CurrentVersion(db *sql.DB) (int, error) {
|
||||
// Check if schema_migrations table exists
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table' AND name='schema_migrations'
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var version int
|
||||
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&version)
|
||||
return version, err
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// sqliteStore implements Store using SQLite.
|
||||
type sqliteStore struct {
|
||||
db *sql.DB
|
||||
tx *sql.Tx // non-nil when operating inside WithTx
|
||||
}
|
||||
|
||||
// Open creates a new SQLite-backed Store at the given path.
|
||||
// It applies all pending migrations and sets optimal PRAGMAs.
|
||||
func Open(dbPath string) (Store, error) {
|
||||
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=cache_size(-64000)&_pragma=foreign_keys(ON)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
||||
}
|
||||
|
||||
// Connection pool settings
|
||||
db.SetMaxOpenConns(1) // SQLite only supports 1 writer
|
||||
db.SetMaxIdleConns(2)
|
||||
db.SetConnMaxLifetime(0) // connections don't expire
|
||||
|
||||
// Verify connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := Migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[store] SQLite store opened: %s", dbPath)
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
// conn returns the underlying *sql.Tx or *sql.DB for executing queries.
|
||||
func (s *sqliteStore) conn() querier {
|
||||
if s.tx != nil {
|
||||
return s.tx
|
||||
}
|
||||
return s.db
|
||||
}
|
||||
|
||||
// querier abstracts *sql.DB and *sql.Tx for query execution.
|
||||
type querier interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
|
||||
}
|
||||
|
||||
// ===================== Node operations =====================
|
||||
|
||||
func (s *sqliteStore) ListNodes(ctx context.Context, filter NodeFilter) ([]Node, error) {
|
||||
query := "SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes"
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
if filter.Source != "" {
|
||||
conditions = append(conditions, "source = ?")
|
||||
args = append(args, filter.Source)
|
||||
}
|
||||
if filter.Region != "" {
|
||||
conditions = append(conditions, "region = ?")
|
||||
args = append(args, filter.Region)
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
conditions = append(conditions, "enabled = ?")
|
||||
if *filter.Enabled {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
query += " ORDER BY id ASC"
|
||||
if filter.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
if filter.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", filter.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.conn().QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list nodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetNode(ctx context.Context, id int64) (*Node, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE id = ?", id)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetNodeByURI(ctx context.Context, uri string) (*Node, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE uri = ?", uri)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetNodeByName(ctx context.Context, name string) (*Node, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE name = ?", name)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CreateNode(ctx context.Context, node *Node) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if node.CreatedAt.IsZero() {
|
||||
node.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if node.UpdatedAt.IsZero() {
|
||||
node.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
enabled := 0
|
||||
if node.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
result, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO nodes (uri, name, source, port, username, password, region, country, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
node.URI, node.Name, node.Source, node.Port,
|
||||
node.Username, node.Password, node.Region, node.Country,
|
||||
enabled, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last insert id: %w", err)
|
||||
}
|
||||
node.ID = id
|
||||
|
||||
// Create initial stats row
|
||||
_, err = s.conn().ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO node_stats (node_id) VALUES (?)", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create initial node stats: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UpdateNode(ctx context.Context, node *Node) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
enabled := 0
|
||||
if node.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
result, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE nodes SET uri=?, name=?, source=?, port=?, username=?, password=?,
|
||||
region=?, country=?, enabled=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
node.URI, node.Name, node.Source, node.Port,
|
||||
node.Username, node.Password, node.Region, node.Country,
|
||||
enabled, now, node.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update node %d: %w", node.ID, err)
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("node %d not found", node.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DeleteNode(ctx context.Context, id int64) error {
|
||||
result, err := s.conn().ExecContext(ctx, "DELETE FROM nodes WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete node %d: %w", id, err)
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("node %d not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DeleteNodesBySource(ctx context.Context, source string) (int64, error) {
|
||||
result, err := s.conn().ExecContext(ctx, "DELETE FROM nodes WHERE source = ?", source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete nodes by source %q: %w", source, err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) BulkUpsertNodes(ctx context.Context, nodes []Node) error {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
execFn := func(txStore *sqliteStore) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stmt, err := txStore.conn().PrepareContext(ctx,
|
||||
`INSERT INTO nodes (uri, name, source, port, username, password, region, country, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(uri) DO UPDATE SET
|
||||
name=excluded.name, source=excluded.source, port=excluded.port,
|
||||
username=excluded.username, password=excluded.password,
|
||||
region=excluded.region, country=excluded.country,
|
||||
updated_at=excluded.updated_at`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare bulk upsert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
enabled := 0
|
||||
if n.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
result, err := stmt.ExecContext(ctx,
|
||||
n.URI, n.Name, n.Source, n.Port,
|
||||
n.Username, n.Password, n.Region, n.Country,
|
||||
enabled, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert node %q: %w", n.URI, err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
if id > 0 {
|
||||
n.ID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Create stats rows for new nodes
|
||||
_, err = txStore.conn().ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO node_stats (node_id) SELECT id FROM nodes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create stats for new nodes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If already in a transaction, execute directly
|
||||
if s.tx != nil {
|
||||
return execFn(s)
|
||||
}
|
||||
|
||||
// Otherwise wrap in a transaction
|
||||
return s.WithTx(ctx, func(tx Store) error {
|
||||
return execFn(tx.(*sqliteStore))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CountNodes(ctx context.Context, filter NodeFilter) (int64, error) {
|
||||
query := "SELECT COUNT(*) FROM nodes"
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
if filter.Source != "" {
|
||||
conditions = append(conditions, "source = ?")
|
||||
args = append(args, filter.Source)
|
||||
}
|
||||
if filter.Region != "" {
|
||||
conditions = append(conditions, "region = ?")
|
||||
args = append(args, filter.Region)
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
conditions = append(conditions, "enabled = ?")
|
||||
if *filter.Enabled {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := s.conn().QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ===================== Node stats =====================
|
||||
|
||||
func (s *sqliteStore) GetNodeStats(ctx context.Context, nodeID int64) (*NodeStats, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
`SELECT node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms,
|
||||
available, initial_check_done, total_upload_bytes, total_download_bytes, updated_at
|
||||
FROM node_stats WHERE node_id = ?`, nodeID)
|
||||
|
||||
stats := &NodeStats{}
|
||||
var blacklistedUntilStr, lastFailureStr, lastSuccessStr, updatedAtStr string
|
||||
var blacklisted, available, initialCheckDone int
|
||||
|
||||
err := row.Scan(
|
||||
&stats.NodeID, &stats.FailureCount, &stats.SuccessCount,
|
||||
&blacklisted, &blacklistedUntilStr,
|
||||
&stats.LastError, &lastFailureStr, &lastSuccessStr,
|
||||
&stats.LastLatencyMs, &available, &initialCheckDone,
|
||||
&stats.TotalUploadBytes, &stats.TotalDownloadBytes, &updatedAtStr,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get node stats %d: %w", nodeID, err)
|
||||
}
|
||||
|
||||
stats.Blacklisted = blacklisted != 0
|
||||
stats.Available = available != 0
|
||||
stats.InitialCheckDone = initialCheckDone != 0
|
||||
stats.BlacklistedUntil = parseTime(blacklistedUntilStr)
|
||||
stats.LastFailureAt = parseTime(lastFailureStr)
|
||||
stats.LastSuccessAt = parseTime(lastSuccessStr)
|
||||
stats.UpdatedAt = parseTime(updatedAtStr)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UpsertNodeStats(ctx context.Context, stats *NodeStats) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
blacklisted := 0
|
||||
if stats.Blacklisted {
|
||||
blacklisted = 1
|
||||
}
|
||||
available := 0
|
||||
if stats.Available {
|
||||
available = 1
|
||||
}
|
||||
initialCheckDone := 0
|
||||
if stats.InitialCheckDone {
|
||||
initialCheckDone = 1
|
||||
}
|
||||
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO node_stats (node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms, available, initial_check_done,
|
||||
total_upload_bytes, total_download_bytes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
failure_count=excluded.failure_count, success_count=excluded.success_count,
|
||||
blacklisted=excluded.blacklisted, blacklisted_until=excluded.blacklisted_until,
|
||||
last_error=excluded.last_error, last_failure_at=excluded.last_failure_at,
|
||||
last_success_at=excluded.last_success_at, last_latency_ms=excluded.last_latency_ms,
|
||||
available=excluded.available, initial_check_done=excluded.initial_check_done,
|
||||
total_upload_bytes=excluded.total_upload_bytes, total_download_bytes=excluded.total_download_bytes,
|
||||
updated_at=excluded.updated_at`,
|
||||
stats.NodeID, stats.FailureCount, stats.SuccessCount,
|
||||
blacklisted, formatTime(stats.BlacklistedUntil),
|
||||
stats.LastError, formatTime(stats.LastFailureAt), formatTime(stats.LastSuccessAt),
|
||||
stats.LastLatencyMs, available, initialCheckDone,
|
||||
stats.TotalUploadBytes, stats.TotalDownloadBytes, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecordSuccess(ctx context.Context, nodeID int64, latencyMs int64) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
success_count = success_count + 1,
|
||||
last_success_at = ?,
|
||||
last_latency_ms = ?,
|
||||
available = 1,
|
||||
initial_check_done = 1,
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
now, latencyMs, now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecordFailure(ctx context.Context, nodeID int64, errMsg string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
failure_count = failure_count + 1,
|
||||
last_error = ?,
|
||||
last_failure_at = ?,
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
errMsg, now, now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SetBlacklist(ctx context.Context, nodeID int64, until time.Time) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
blacklisted = 1,
|
||||
blacklisted_until = ?,
|
||||
failure_count = 0,
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
formatTime(until), now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) ClearBlacklist(ctx context.Context, nodeID int64) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
blacklisted = 0,
|
||||
blacklisted_until = '',
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) ClearAllBlacklists(ctx context.Context) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET blacklisted = 0, blacklisted_until = '', updated_at = ? WHERE blacklisted = 1`,
|
||||
now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) BatchUpdateStats(ctx context.Context, updates []StatsUpdate) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
execFn := func(txStore *sqliteStore) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stmt, err := txStore.conn().PrepareContext(ctx,
|
||||
`INSERT INTO node_stats (node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms, available, initial_check_done,
|
||||
total_upload_bytes, total_download_bytes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
failure_count=excluded.failure_count, success_count=excluded.success_count,
|
||||
blacklisted=excluded.blacklisted, blacklisted_until=excluded.blacklisted_until,
|
||||
last_error=excluded.last_error, last_failure_at=excluded.last_failure_at,
|
||||
last_success_at=excluded.last_success_at, last_latency_ms=excluded.last_latency_ms,
|
||||
available=excluded.available, initial_check_done=excluded.initial_check_done,
|
||||
total_upload_bytes=excluded.total_upload_bytes, total_download_bytes=excluded.total_download_bytes,
|
||||
updated_at=excluded.updated_at`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare batch stats: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, u := range updates {
|
||||
blacklisted := 0
|
||||
if u.Blacklisted {
|
||||
blacklisted = 1
|
||||
}
|
||||
available := 0
|
||||
if u.Available {
|
||||
available = 1
|
||||
}
|
||||
initialCheckDone := 0
|
||||
if u.InitialCheckDone {
|
||||
initialCheckDone = 1
|
||||
}
|
||||
|
||||
_, err := stmt.ExecContext(ctx,
|
||||
u.NodeID, u.FailureCount, u.SuccessCount,
|
||||
blacklisted, formatTime(u.BlacklistedUntil),
|
||||
u.LastError, formatTime(u.LastFailureAt), formatTime(u.LastSuccessAt),
|
||||
u.LastLatencyMs, available, initialCheckDone,
|
||||
u.TotalUploadBytes, u.TotalDownloadBytes, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update stats for node %d: %w", u.NodeID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.tx != nil {
|
||||
return execFn(s)
|
||||
}
|
||||
return s.WithTx(ctx, func(tx Store) error {
|
||||
return execFn(tx.(*sqliteStore))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetAllNodeStats(ctx context.Context) (map[int64]*NodeStats, error) {
|
||||
rows, err := s.conn().QueryContext(ctx,
|
||||
`SELECT node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms,
|
||||
available, initial_check_done, total_upload_bytes, total_download_bytes, updated_at
|
||||
FROM node_stats`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get all node stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64]*NodeStats)
|
||||
for rows.Next() {
|
||||
stats := &NodeStats{}
|
||||
var blacklistedUntilStr, lastFailureStr, lastSuccessStr, updatedAtStr string
|
||||
var blacklisted, available, initialCheckDone int
|
||||
|
||||
err := rows.Scan(
|
||||
&stats.NodeID, &stats.FailureCount, &stats.SuccessCount,
|
||||
&blacklisted, &blacklistedUntilStr,
|
||||
&stats.LastError, &lastFailureStr, &lastSuccessStr,
|
||||
&stats.LastLatencyMs, &available, &initialCheckDone,
|
||||
&stats.TotalUploadBytes, &stats.TotalDownloadBytes, &updatedAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan node stats: %w", err)
|
||||
}
|
||||
|
||||
stats.Blacklisted = blacklisted != 0
|
||||
stats.Available = available != 0
|
||||
stats.InitialCheckDone = initialCheckDone != 0
|
||||
stats.BlacklistedUntil = parseTime(blacklistedUntilStr)
|
||||
stats.LastFailureAt = parseTime(lastFailureStr)
|
||||
stats.LastSuccessAt = parseTime(lastSuccessStr)
|
||||
stats.UpdatedAt = parseTime(updatedAtStr)
|
||||
|
||||
result[stats.NodeID] = stats
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ===================== Timeline =====================
|
||||
|
||||
func (s *sqliteStore) AppendTimeline(ctx context.Context, nodeID int64, event TimelineEvent) error {
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO node_timeline (node_id, success, latency_ms, error, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
nodeID, boolToInt(event.Success), event.LatencyMs, event.Error,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetTimeline(ctx context.Context, nodeID int64, limit int) ([]TimelineEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.conn().QueryContext(ctx,
|
||||
`SELECT id, node_id, success, latency_ms, error, created_at
|
||||
FROM node_timeline WHERE node_id = ?
|
||||
ORDER BY id DESC LIMIT ?`,
|
||||
nodeID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get timeline for node %d: %w", nodeID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []TimelineEvent
|
||||
for rows.Next() {
|
||||
var evt TimelineEvent
|
||||
var success int
|
||||
var createdAtStr string
|
||||
err := rows.Scan(&evt.ID, &evt.NodeID, &success, &evt.LatencyMs, &evt.Error, &createdAtStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan timeline event: %w", err)
|
||||
}
|
||||
evt.Success = success != 0
|
||||
evt.CreatedAt = parseTime(createdAtStr)
|
||||
events = append(events, evt)
|
||||
}
|
||||
|
||||
// Reverse to get chronological order
|
||||
for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
|
||||
events[i], events[j] = events[j], events[i]
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CleanupTimeline(ctx context.Context, keepPerNode int) error {
|
||||
if keepPerNode <= 0 {
|
||||
keepPerNode = 20
|
||||
}
|
||||
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`DELETE FROM node_timeline WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY id DESC) as rn
|
||||
FROM node_timeline
|
||||
) WHERE rn <= ?
|
||||
)`, keepPerNode,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ===================== Sessions =====================
|
||||
|
||||
func (s *sqliteStore) CreateSession(ctx context.Context, session *Session) error {
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)`,
|
||||
session.Token,
|
||||
session.CreatedAt.UTC().Format(time.RFC3339),
|
||||
session.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetSession(ctx context.Context, token string) (*Session, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT token, created_at, expires_at FROM sessions WHERE token = ?", token)
|
||||
|
||||
var sess Session
|
||||
var createdAtStr, expiresAtStr string
|
||||
err := row.Scan(&sess.Token, &createdAtStr, &expiresAtStr)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
|
||||
sess.CreatedAt = parseTime(createdAtStr)
|
||||
sess.ExpiresAt = parseTime(expiresAtStr)
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := s.conn().ExecContext(ctx, "DELETE FROM sessions WHERE token = ?", token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CleanupExpiredSessions(ctx context.Context) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx, "DELETE FROM sessions WHERE expires_at < ?", now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ===================== Subscription status =====================
|
||||
|
||||
func (s *sqliteStore) GetSubscriptionStatus(ctx context.Context) (*SubscriptionStatus, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
`SELECT last_refresh, next_refresh, node_count, last_error,
|
||||
refresh_count, is_refreshing, nodes_hash, updated_at
|
||||
FROM subscription_status WHERE id = 1`)
|
||||
|
||||
var status SubscriptionStatus
|
||||
var lastRefreshStr, nextRefreshStr, updatedAtStr string
|
||||
var isRefreshing int
|
||||
|
||||
err := row.Scan(
|
||||
&lastRefreshStr, &nextRefreshStr, &status.NodeCount,
|
||||
&status.LastError, &status.RefreshCount, &isRefreshing,
|
||||
&status.NodesHash, &updatedAtStr,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return &SubscriptionStatus{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get subscription status: %w", err)
|
||||
}
|
||||
|
||||
status.IsRefreshing = isRefreshing != 0
|
||||
status.LastRefresh = parseTime(lastRefreshStr)
|
||||
status.NextRefresh = parseTime(nextRefreshStr)
|
||||
status.UpdatedAt = parseTime(updatedAtStr)
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UpdateSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
isRefreshing := 0
|
||||
if status.IsRefreshing {
|
||||
isRefreshing = 1
|
||||
}
|
||||
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO subscription_status (id, last_refresh, next_refresh, node_count, last_error,
|
||||
refresh_count, is_refreshing, nodes_hash, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_refresh=excluded.last_refresh, next_refresh=excluded.next_refresh,
|
||||
node_count=excluded.node_count, last_error=excluded.last_error,
|
||||
refresh_count=excluded.refresh_count, is_refreshing=excluded.is_refreshing,
|
||||
nodes_hash=excluded.nodes_hash, updated_at=excluded.updated_at`,
|
||||
formatTime(status.LastRefresh), formatTime(status.NextRefresh),
|
||||
status.NodeCount, status.LastError, status.RefreshCount,
|
||||
isRefreshing, status.NodesHash, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ===================== Lifecycle =====================
|
||||
|
||||
func (s *sqliteStore) Close() error {
|
||||
if s.db != nil {
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) WithTx(ctx context.Context, fn func(tx Store) error) error {
|
||||
if s.tx != nil {
|
||||
// Already in a transaction, just execute
|
||||
return fn(s)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
txStore := &sqliteStore{db: s.db, tx: tx}
|
||||
if err := fn(txStore); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ===================== Helpers =====================
|
||||
|
||||
func scanNode(row *sql.Row) (*Node, error) {
|
||||
var n Node
|
||||
var enabled int
|
||||
var createdAtStr, updatedAtStr string
|
||||
|
||||
err := row.Scan(
|
||||
&n.ID, &n.URI, &n.Name, &n.Source, &n.Port,
|
||||
&n.Username, &n.Password, &n.Region, &n.Country,
|
||||
&enabled, &createdAtStr, &updatedAtStr,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Enabled = enabled != 0
|
||||
n.CreatedAt = parseTime(createdAtStr)
|
||||
n.UpdatedAt = parseTime(updatedAtStr)
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func scanNodes(rows *sql.Rows) ([]Node, error) {
|
||||
var nodes []Node
|
||||
for rows.Next() {
|
||||
var n Node
|
||||
var enabled int
|
||||
var createdAtStr, updatedAtStr string
|
||||
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.URI, &n.Name, &n.Source, &n.Port,
|
||||
&n.Username, &n.Password, &n.Region, &n.Country,
|
||||
&enabled, &createdAtStr, &updatedAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Enabled = enabled != 0
|
||||
n.CreatedAt = parseTime(createdAtStr)
|
||||
n.UpdatedAt = parseTime(updatedAtStr)
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes, rows.Err()
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
// Try other formats
|
||||
t, err = time.Parse("2006-01-02 15:04:05", s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package store defines the data persistence layer for easy_proxies.
|
||||
// It abstracts all storage operations behind the Store interface,
|
||||
// allowing different backend implementations (e.g., SQLite).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store defines all data storage operations.
|
||||
type Store interface {
|
||||
// --- Node operations ---
|
||||
|
||||
// ListNodes returns nodes matching the given filter.
|
||||
ListNodes(ctx context.Context, filter NodeFilter) ([]Node, error)
|
||||
|
||||
// GetNode returns a node by its ID.
|
||||
GetNode(ctx context.Context, id int64) (*Node, error)
|
||||
|
||||
// GetNodeByURI returns a node by its URI.
|
||||
GetNodeByURI(ctx context.Context, uri string) (*Node, error)
|
||||
|
||||
// GetNodeByName returns a node by its name.
|
||||
GetNodeByName(ctx context.Context, name string) (*Node, error)
|
||||
|
||||
// CreateNode inserts a new node and returns its assigned ID.
|
||||
CreateNode(ctx context.Context, node *Node) error
|
||||
|
||||
// UpdateNode updates an existing node by ID.
|
||||
UpdateNode(ctx context.Context, node *Node) error
|
||||
|
||||
// DeleteNode removes a node by ID (cascading to stats/timeline).
|
||||
DeleteNode(ctx context.Context, id int64) error
|
||||
|
||||
// DeleteNodesBySource removes all nodes with the given source.
|
||||
// Returns the number of deleted rows.
|
||||
DeleteNodesBySource(ctx context.Context, source string) (int64, error)
|
||||
|
||||
// BulkUpsertNodes inserts or updates nodes in a single transaction.
|
||||
// Nodes are matched by URI for upsert logic.
|
||||
BulkUpsertNodes(ctx context.Context, nodes []Node) error
|
||||
|
||||
// CountNodes returns the total number of nodes matching the filter.
|
||||
CountNodes(ctx context.Context, filter NodeFilter) (int64, error)
|
||||
|
||||
// --- Node stats ---
|
||||
|
||||
// GetNodeStats returns runtime statistics for a node.
|
||||
GetNodeStats(ctx context.Context, nodeID int64) (*NodeStats, error)
|
||||
|
||||
// UpsertNodeStats creates or updates node statistics.
|
||||
UpsertNodeStats(ctx context.Context, stats *NodeStats) error
|
||||
|
||||
// RecordSuccess increments success count and updates latency.
|
||||
RecordSuccess(ctx context.Context, nodeID int64, latencyMs int64) error
|
||||
|
||||
// RecordFailure increments failure count and records the error.
|
||||
RecordFailure(ctx context.Context, nodeID int64, errMsg string) error
|
||||
|
||||
// SetBlacklist marks a node as blacklisted until the given time.
|
||||
SetBlacklist(ctx context.Context, nodeID int64, until time.Time) error
|
||||
|
||||
// ClearBlacklist removes the blacklist flag for a node.
|
||||
ClearBlacklist(ctx context.Context, nodeID int64) error
|
||||
|
||||
// ClearAllBlacklists removes all blacklist flags.
|
||||
ClearAllBlacklists(ctx context.Context) error
|
||||
|
||||
// BatchUpdateStats applies multiple stat updates in a single transaction.
|
||||
BatchUpdateStats(ctx context.Context, updates []StatsUpdate) error
|
||||
|
||||
// GetAllNodeStats returns stats for all nodes (used for bulk restore).
|
||||
GetAllNodeStats(ctx context.Context) (map[int64]*NodeStats, error)
|
||||
|
||||
// --- Timeline ---
|
||||
|
||||
// AppendTimeline adds an event to a node's timeline.
|
||||
AppendTimeline(ctx context.Context, nodeID int64, event TimelineEvent) error
|
||||
|
||||
// GetTimeline returns the most recent events for a node.
|
||||
GetTimeline(ctx context.Context, nodeID int64, limit int) ([]TimelineEvent, error)
|
||||
|
||||
// CleanupTimeline removes old timeline events, keeping only the most recent per node.
|
||||
CleanupTimeline(ctx context.Context, keepPerNode int) error
|
||||
|
||||
// --- Sessions ---
|
||||
|
||||
// CreateSession stores a new session.
|
||||
CreateSession(ctx context.Context, session *Session) error
|
||||
|
||||
// GetSession retrieves a session by token.
|
||||
GetSession(ctx context.Context, token string) (*Session, error)
|
||||
|
||||
// DeleteSession removes a session by token.
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
|
||||
// CleanupExpiredSessions removes all expired sessions.
|
||||
CleanupExpiredSessions(ctx context.Context) error
|
||||
|
||||
// --- Subscription status ---
|
||||
|
||||
// GetSubscriptionStatus returns the current subscription refresh status.
|
||||
GetSubscriptionStatus(ctx context.Context) (*SubscriptionStatus, error)
|
||||
|
||||
// UpdateSubscriptionStatus creates or updates the subscription status.
|
||||
UpdateSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) error
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
// Close releases all resources held by the store.
|
||||
Close() error
|
||||
|
||||
// WithTx executes fn within a transaction. If fn returns an error,
|
||||
// the transaction is rolled back; otherwise it is committed.
|
||||
WithTx(ctx context.Context, fn func(tx Store) error) error
|
||||
}
|
||||
|
||||
// --- Data models ---
|
||||
|
||||
// Node represents a proxy node stored in the database.
|
||||
type Node struct {
|
||||
ID int64 `json:"id"`
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"` // inline, nodes_file, subscription, manual
|
||||
Port uint16 `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NodeFilter specifies criteria for listing nodes.
|
||||
type NodeFilter struct {
|
||||
Source string // Filter by source (empty = all)
|
||||
Region string // Filter by region (empty = all)
|
||||
Enabled *bool // Filter by enabled status (nil = all)
|
||||
Limit int // Max results (0 = no limit)
|
||||
Offset int // Pagination offset
|
||||
}
|
||||
|
||||
// NodeStats holds runtime statistics for a node.
|
||||
type NodeStats struct {
|
||||
NodeID int64 `json:"node_id"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
BlacklistedUntil time.Time `json:"blacklisted_until"`
|
||||
LastError string `json:"last_error"`
|
||||
LastFailureAt time.Time `json:"last_failure_at"`
|
||||
LastSuccessAt time.Time `json:"last_success_at"`
|
||||
LastLatencyMs int64 `json:"last_latency_ms"` // -1 = untested
|
||||
Available bool `json:"available"`
|
||||
InitialCheckDone bool `json:"initial_check_done"`
|
||||
TotalUploadBytes int64 `json:"total_upload_bytes"`
|
||||
TotalDownloadBytes int64 `json:"total_download_bytes"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StatsUpdate represents a batch update for node statistics.
|
||||
type StatsUpdate struct {
|
||||
NodeID int64
|
||||
FailureCount int
|
||||
SuccessCount int64
|
||||
Blacklisted bool
|
||||
BlacklistedUntil time.Time
|
||||
LastError string
|
||||
LastFailureAt time.Time
|
||||
LastSuccessAt time.Time
|
||||
LastLatencyMs int64
|
||||
Available bool
|
||||
InitialCheckDone bool
|
||||
TotalUploadBytes int64
|
||||
TotalDownloadBytes int64
|
||||
}
|
||||
|
||||
// TimelineEvent represents a single usage event for debug tracking.
|
||||
type TimelineEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
NodeID int64 `json:"node_id"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Session represents a user authentication session.
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// SubscriptionStatus represents subscription refresh status.
|
||||
type SubscriptionStatus struct {
|
||||
LastRefresh time.Time `json:"last_refresh"`
|
||||
NextRefresh time.Time `json:"next_refresh"`
|
||||
NodeCount int `json:"node_count"`
|
||||
LastError string `json:"last_error"`
|
||||
RefreshCount int `json:"refresh_count"`
|
||||
IsRefreshing bool `json:"is_refreshing"`
|
||||
NodesHash string `json:"nodes_hash"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Node source constants (matching config.NodeSource values).
|
||||
const (
|
||||
NodeSourceInline = "inline"
|
||||
NodeSourceFile = "nodes_file"
|
||||
NodeSourceSubscription = "subscription"
|
||||
NodeSourceManual = "manual"
|
||||
)
|
||||
Reference in New Issue
Block a user