chore: 准备发布 v1.0.0 版本

This commit is contained in:
xiamuceer-j
2026-03-02 13:13:33 +08:00
parent f57ae3c712
commit 8163e4c193
58 changed files with 19545 additions and 1 deletions
+212
View File
@@ -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
}
+196
View File
@@ -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(&currentVersion); 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
}
+816
View File
@@ -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
}
+216
View File
@@ -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"
)