feat(routing): routing_profiles 表 + Profile 类型 + store(双方言)
This commit is contained in:
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
|||||||
if dirty {
|
if dirty {
|
||||||
t.Fatalf("schema dirty after MigrateUp")
|
t.Fatalf("schema dirty after MigrateUp")
|
||||||
}
|
}
|
||||||
if v != 27 {
|
if v != 28 {
|
||||||
t.Errorf("version = %d, want 27", v)
|
t.Errorf("version = %d, want 28", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Core tables exist.
|
// 2. Core tables exist.
|
||||||
@@ -39,7 +39,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
|||||||
"usage_daily", "audit_log", "providers", "nodes", "node_events",
|
"usage_daily", "audit_log", "providers", "nodes", "node_events",
|
||||||
"directory_version", "provision_idempotency", "replacements", "admins",
|
"directory_version", "provision_idempotency", "replacements", "admins",
|
||||||
"connect_credentials", "usage_device_daily", "sessions", "usage_hourly", "usage_device_hourly",
|
"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
|
var name string
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS routing_profiles;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS routing_profiles;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user