diff --git a/server/internal/routing/profile.go b/server/internal/routing/profile.go new file mode 100644 index 0000000..f4fe018 --- /dev/null +++ b/server/internal/routing/profile.go @@ -0,0 +1,42 @@ +// Package routing holds the per-user routing profile (可配置分流): the rule +// model, persistence (Store), and — in later tasks — validation and sing-box +// config rendering. +package routing + +// Rule is a single user-authored routing rule (domain/IP/geosite match → +// proxy/direct action). Validation of Type/Action/Value lives in Task 2. +type Rule struct { + Type string `json:"type"` + Value string `json:"value"` + Action string `json:"action"` + Note string `json:"note,omitempty"` + Enabled bool `json:"enabled"` +} + +// Builtin toggles the built-in routing behaviors that previously were +// hardcoded into the server's sing-box config rendering. +type Builtin struct { + ChinaDirect bool `json:"china_direct"` + LanDirect bool `json:"lan_direct"` + PrivateViaTunnel bool `json:"private_via_tunnel"` +} + +// Profile is a user's full routing configuration, persisted as JSON in +// routing_profiles.profile_json. +type Profile struct { + Mode string `json:"mode"` // rule | global | direct + Builtin Builtin `json:"builtin"` + Rules []Rule `json:"rules"` + Final string `json:"final"` // proxy | direct +} + +// Default is the fail-safe fallback profile, equivalent to today's hardcoded +// behavior (smart routing + China direct + no user rules). +func Default() *Profile { + return &Profile{ + Mode: "rule", + Builtin: Builtin{ChinaDirect: true, LanDirect: true, PrivateViaTunnel: true}, + Rules: []Rule{}, + Final: "proxy", + } +} diff --git a/server/internal/routing/store.go b/server/internal/routing/store.go new file mode 100644 index 0000000..9f8f4e9 --- /dev/null +++ b/server/internal/routing/store.go @@ -0,0 +1,57 @@ +package routing + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + dbx "github.com/wangjia/pangolin/server/internal/db" +) + +// Store wraps a *sql.DB and exposes the routing_profiles CRUD the routing +// package needs. +type Store struct { + db *sql.DB + dialect dbx.Dialect +} + +// NewStore creates a Store backed by the given connection pool (MySQL or SQLite). +func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} } + +// Get returns the user's routing profile, or (nil, nil) if none is set yet +// (caller should fall back to Default()). +func (s *Store) Get(ctx context.Context, userID int64) (*Profile, error) { + var raw string + err := s.db.QueryRowContext(ctx, + `SELECT profile_json FROM routing_profiles WHERE user_id = ?`, userID).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("store.Get: %w", err) + } + var p Profile + if err := json.Unmarshal([]byte(raw), &p); err != nil { + return nil, fmt.Errorf("store.Get: unmarshal: %w", err) + } + return &p, nil +} + +// Upsert creates or overwrites the user's routing profile. +func (s *Store) Upsert(ctx context.Context, userID int64, p *Profile) error { + raw, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("store.Upsert: marshal: %w", err) + } + now := time.Now().UTC() + q := `INSERT INTO routing_profiles (user_id, profile_json, updated_at) VALUES (?,?,?) ` + + s.dialect.Upsert([]string{"user_id"}, + "profile_json = EXCLUDED.profile_json", "updated_at = EXCLUDED.updated_at") + if _, err := s.db.ExecContext(ctx, q, userID, string(raw), now); err != nil { + return fmt.Errorf("store.Upsert: %w", err) + } + return nil +} diff --git a/server/internal/routing/store_sqlite_test.go b/server/internal/routing/store_sqlite_test.go new file mode 100644 index 0000000..f479504 --- /dev/null +++ b/server/internal/routing/store_sqlite_test.go @@ -0,0 +1,62 @@ +package routing + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openDB(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatal(err) + } + _ = store.ApplyCodesLibMigrations(context.Background(), db, "sqlite") + return db +} + +func seedU(t *testing.T, db *sql.DB, id int64, uuid string) { + t.Helper() + if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) + VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteRoutingStoreUpsertGet(t *testing.T) { + db := openDB(t) // 内存库 + MigrateUp(sqlite) + seedU(t, db, 1, "u1") + st := NewStore(db) + ctx := context.Background() + // 无档案 → nil,nil + got, err := st.Get(ctx, 1) + if err != nil || got != nil { + t.Fatalf("empty want nil,nil got %v,%v", got, err) + } + // Upsert 后可取回 + p := Default() + p.Final = "direct" + if err := st.Upsert(ctx, 1, p); err != nil { + t.Fatal(err) + } + got, err = st.Get(ctx, 1) + if err != nil || got == nil || got.Final != "direct" { + t.Fatalf("got %v,%v", got, err) + } + // 二次 Upsert 覆盖 + p.Final = "proxy" + _ = st.Upsert(ctx, 1, p) + got, _ = st.Get(ctx, 1) + if got.Final != "proxy" { + t.Fatalf("upsert overwrite failed: %s", got.Final) + } +} diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index ab79d56..6c930e1 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 27 { - t.Errorf("version = %d, want 27", v) + if v != 28 { + t.Errorf("version = %d, want 28", v) } // 2. Core tables exist. @@ -39,7 +39,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) { "usage_daily", "audit_log", "providers", "nodes", "node_events", "directory_version", "provision_idempotency", "replacements", "admins", "connect_credentials", "usage_device_daily", "sessions", "usage_hourly", "usage_device_hourly", - "pay_purchases", "referrals", "reward_claims", "notices", + "pay_purchases", "referrals", "reward_claims", "notices", "routing_profiles", } { var name string err := db.QueryRow( diff --git a/server/migrations/mysql/000028_routing_profiles.down.sql b/server/migrations/mysql/000028_routing_profiles.down.sql new file mode 100644 index 0000000..446f345 --- /dev/null +++ b/server/migrations/mysql/000028_routing_profiles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS routing_profiles; diff --git a/server/migrations/mysql/000028_routing_profiles.up.sql b/server/migrations/mysql/000028_routing_profiles.up.sql new file mode 100644 index 0000000..530bc1a --- /dev/null +++ b/server/migrations/mysql/000028_routing_profiles.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE routing_profiles ( + user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + profile_json TEXT NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_routing_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/migrations/sqlite/000028_routing_profiles.down.sql b/server/migrations/sqlite/000028_routing_profiles.down.sql new file mode 100644 index 0000000..446f345 --- /dev/null +++ b/server/migrations/sqlite/000028_routing_profiles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS routing_profiles; diff --git a/server/migrations/sqlite/000028_routing_profiles.up.sql b/server/migrations/sqlite/000028_routing_profiles.up.sql new file mode 100644 index 0000000..2510345 --- /dev/null +++ b/server/migrations/sqlite/000028_routing_profiles.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE routing_profiles ( + user_id INTEGER NOT NULL PRIMARY KEY, + profile_json TEXT NOT NULL, + updated_at DATETIME NOT NULL +);