From 50678756d4e726ad314c2be728594855cdbee130 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 12 Jul 2026 14:23:25 +0800 Subject: [PATCH] feat(zone): add normalized zone domain schema --- docs/changelog/index.md | 4 + ...202607120001_create_zone_domain_tables.sql | 28 +++++ ...202607120001_create_zone_domain_tables.sql | 28 +++++ internal/db/migrator/migrator_test.go | 23 ++++ internal/model/openflare_proxy_route.go | 65 +++++----- internal/model/openflare_zone.go | 115 ++++++++++++++++++ internal/model/openflare_zone_test.go | 88 ++++++++++++++ 7 files changed, 319 insertions(+), 32 deletions(-) create mode 100644 internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql create mode 100644 internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql create mode 100644 internal/model/openflare_zone.go create mode 100644 internal/model/openflare_zone_test.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 6e568fbf..7f269017 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -21,6 +21,10 @@ sidebar: false ## [unreleased] +### 新增 + +- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。 + ### 修复 - Docker ClickHouse 性能配置改为单文件挂载,避免覆盖镜像内置的 Docker 网络监听配置,导致宿主机无法通过 8123/9000 访问服务。 diff --git a/internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql b/internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql new file mode 100644 index 00000000..00b8170e --- /dev/null +++ b/internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql @@ -0,0 +1,28 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS of_zones ( + id BIGSERIAL PRIMARY KEY, + domain VARCHAR(255) NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain); + +CREATE TABLE IF NOT EXISTS of_zone_domains ( + id BIGSERIAL PRIMARY KEY, + zone_id BIGINT NOT NULL, + proxy_route_id BIGINT, + domain VARCHAR(255) NOT NULL, + cert_id BIGINT, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id); + +-- +goose Down +DROP TABLE IF EXISTS of_zone_domains; +DROP TABLE IF EXISTS of_zones; diff --git a/internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql b/internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql new file mode 100644 index 00000000..637fe80e --- /dev/null +++ b/internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql @@ -0,0 +1,28 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS of_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL, + remark TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain); + +CREATE TABLE IF NOT EXISTS of_zone_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id INTEGER NOT NULL, + proxy_route_id INTEGER, + domain TEXT NOT NULL, + cert_id INTEGER, + remark TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id); +CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id); + +-- +goose Down +DROP TABLE IF EXISTS of_zone_domains; +DROP TABLE IF EXISTS of_zones; diff --git a/internal/db/migrator/migrator_test.go b/internal/db/migrator/migrator_test.go index 7badfd4b..27238aa4 100644 --- a/internal/db/migrator/migrator_test.go +++ b/internal/db/migrator/migrator_test.go @@ -76,6 +76,29 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) { if templateCount != 2 { t.Errorf("Migrate() templates count = %d, want %d", templateCount, 2) } + + if !sqliteDB.Migrator().HasTable("of_zones") { + t.Error("Migrate() did not create of_zones") + } + if !sqliteDB.Migrator().HasTable("of_zone_domains") { + t.Error("Migrate() did not create of_zone_domains") + } + + zone := model.Zone{Domain: "example.com"} + if err := sqliteDB.Create(&zone).Error; err != nil { + t.Fatalf("Migrate() create Zone error = %v", err) + } + if err := sqliteDB.Create(&model.Zone{Domain: zone.Domain}).Error; err == nil { + t.Error("Migrate() allowed duplicate of_zones.domain") + } + + domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "api.example.com"} + if err := sqliteDB.Create(&domain).Error; err != nil { + t.Fatalf("Migrate() create ZoneDomain error = %v", err) + } + if err := sqliteDB.Create(&model.ZoneDomain{ZoneID: zone.ID, Domain: domain.Domain}).Error; err == nil { + t.Error("Migrate() allowed duplicate of_zone_domains.domain") + } } func TestMigrateClearsStaleSystemConfigCache(t *testing.T) { diff --git a/internal/model/openflare_proxy_route.go b/internal/model/openflare_proxy_route.go index 4a584bf6..7139081b 100644 --- a/internal/model/openflare_proxy_route.go +++ b/internal/model/openflare_proxy_route.go @@ -12,38 +12,39 @@ import ( // ProxyRoute OpenFlare 代理规则实体。 type ProxyRoute struct { - ID uint `json:"id" gorm:"primaryKey;autoIncrement"` - SiteName string `json:"site_name" gorm:"size:255;not null;default:''"` - Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` - Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"` - OriginID *uint `json:"origin_id" gorm:"index"` - OriginURL string `json:"origin_url" gorm:"size:2048;not null"` - OriginHost string `json:"origin_host" gorm:"size:255"` - Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` - CertID *uint `json:"cert_id"` - CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"` - DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"` - RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` - LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"` - LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"` - LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"` - CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"` - CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"` - CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"` - CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"` - BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"` - BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"` - BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"` - Remark string `json:"remark" gorm:"size:255"` - UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"` - TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"` - TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"` - TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"` - PagesProjectID *uint `json:"pages_project_id" gorm:"index"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + SiteName string `json:"site_name" gorm:"size:255;not null;default:''"` + Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` + Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"` + OriginID *uint `json:"origin_id" gorm:"index"` + OriginURL string `json:"origin_url" gorm:"size:2048;not null"` + OriginHost string `json:"origin_host" gorm:"size:255"` + Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"` + Enabled bool `json:"enabled" gorm:"not null;default:true"` + EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` + CertID *uint `json:"cert_id"` + CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"` + DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"` + RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` + LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"` + LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"` + LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"` + CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"` + CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"` + CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"` + CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"` + BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"` + BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"` + BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"` + Remark string `json:"remark" gorm:"size:255"` + UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"` + TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"` + TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"` + TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"` + PagesProjectID *uint `json:"pages_project_id" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + ZoneDomains []ZoneDomain `json:"zone_domains,omitempty" gorm:"foreignKey:ProxyRouteID"` } // TableName 表名。 diff --git a/internal/model/openflare_zone.go b/internal/model/openflare_zone.go new file mode 100644 index 00000000..a9e42f45 --- /dev/null +++ b/internal/model/openflare_zone.go @@ -0,0 +1,115 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Rain-kl/Wavelet/internal/db" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + tableOfZones = "of_zones" + tableOfZoneDomains = "of_zone_domains" +) + +var errZoneDomainBoundToAnotherRoute = errors.New("zone domain is already bound to another proxy route") + +// Zone OpenFlare 注册根域实体。 +type Zone struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + Domain string `json:"domain" gorm:"uniqueIndex:idx_of_zones_domain;size:255;not null"` + Remark string `json:"remark" gorm:"size:255;not null;default:''"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名。 +func (Zone) TableName() string { + return tableOfZones +} + +// ZoneDomain OpenFlare Zone 下的明确域名实体。 +type ZoneDomain struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + ZoneID uint `json:"zone_id" gorm:"not null;index:idx_of_zone_domains_zone_id"` + ProxyRouteID *uint `json:"proxy_route_id" gorm:"index:idx_of_zone_domains_proxy_route_id"` + Domain string `json:"domain" gorm:"uniqueIndex:idx_of_zone_domains_domain;size:255;not null"` + CertID *uint `json:"cert_id" gorm:"index:idx_of_zone_domains_cert_id"` + Remark string `json:"remark" gorm:"size:255;not null;default:''"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名。 +func (ZoneDomain) TableName() string { + return tableOfZoneDomains +} + +// ListZoneDomainsByRouteID returns the domains bound to a proxy route. +func ListZoneDomainsByRouteID(ctx context.Context, routeID uint) ([]ZoneDomain, error) { + var domains []ZoneDomain + if err := db.DB(ctx).Where("proxy_route_id = ?", routeID).Order("id asc").Find(&domains).Error; err != nil { + return nil, err + } + return domains, nil +} + +// ReplaceZoneDomainRouteBindings replaces every ZoneDomain binding for a proxy route. +func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs []uint) error { + conn := db.DB(ctx) + if conn == nil { + return errors.New("database is not initialized") + } + + return conn.Transaction(func(tx *gorm.DB) error { + var requested []ZoneDomain + if len(domainIDs) > 0 { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id IN ?", domainIDs). + Find(&requested).Error; err != nil { + return err + } + if len(requested) != len(uniqueZoneDomainIDs(domainIDs)) { + return fmt.Errorf("one or more zone domains do not exist") + } + for _, domain := range requested { + if domain.ProxyRouteID != nil && *domain.ProxyRouteID != routeID { + return errZoneDomainBoundToAnotherRoute + } + } + } + + current := tx.Model(&ZoneDomain{}).Where("proxy_route_id = ?", routeID) + if len(domainIDs) > 0 { + current = current.Where("id NOT IN ?", domainIDs) + } + if err := current.Update("proxy_route_id", nil).Error; err != nil { + return err + } + + if len(domainIDs) == 0 { + return nil + } + return tx.Model(&ZoneDomain{}).Where("id IN ?", domainIDs).Update("proxy_route_id", routeID).Error + }) +} + +func uniqueZoneDomainIDs(domainIDs []uint) []uint { + seen := make(map[uint]struct{}, len(domainIDs)) + ids := make([]uint, 0, len(domainIDs)) + for _, id := range domainIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids +} diff --git a/internal/model/openflare_zone_test.go b/internal/model/openflare_zone_test.go new file mode 100644 index 00000000..c811a77f --- /dev/null +++ b/internal/model/openflare_zone_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "context" + "testing" + + "github.com/Rain-kl/Wavelet/internal/db" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupZoneTestDB(t *testing.T) *gorm.DB { + t.Helper() + + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + require.NoError(t, err) + require.NoError(t, sqliteDB.AutoMigrate(&Zone{}, &ZoneDomain{})) + db.SetDB(sqliteDB) + t.Cleanup(func() { db.SetDB(nil) }) + return sqliteDB +} + +func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) { + conn := setupZoneTestDB(t) + ctx := context.Background() + + zone := Zone{Domain: "example.com"} + require.NoError(t, conn.Create(&zone).Error) + foreignRouteID := uint(11) + domain := ZoneDomain{ + ZoneID: zone.ID, + ProxyRouteID: &foreignRouteID, + Domain: "api.example.com", + } + require.NoError(t, conn.Create(&domain).Error) + + err := ReplaceZoneDomainRouteBindings(ctx, 12, []uint{domain.ID}) + require.Error(t, err) + + var got ZoneDomain + require.NoError(t, conn.First(&got, domain.ID).Error) + require.Equal(t, &foreignRouteID, got.ProxyRouteID) +} + +func TestReplaceZoneDomainRouteBindingsReplacesCurrentRouteBindings(t *testing.T) { + conn := setupZoneTestDB(t) + ctx := context.Background() + + zone := Zone{Domain: "example.com"} + require.NoError(t, conn.Create(&zone).Error) + routeID := uint(21) + boundDomain := ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &routeID, Domain: "old.example.com"} + requestedDomain := ZoneDomain{ZoneID: zone.ID, Domain: "new.example.com"} + require.NoError(t, conn.Create(&boundDomain).Error) + require.NoError(t, conn.Create(&requestedDomain).Error) + + require.NoError(t, ReplaceZoneDomainRouteBindings(ctx, routeID, []uint{requestedDomain.ID})) + + var domains []ZoneDomain + require.NoError(t, conn.Order("id asc").Find(&domains).Error) + require.Len(t, domains, 2) + require.Nil(t, domains[0].ProxyRouteID) + require.Equal(t, &routeID, domains[1].ProxyRouteID) +} + +func TestListZoneDomainsByRouteID(t *testing.T) { + conn := setupZoneTestDB(t) + ctx := context.Background() + + zone := Zone{Domain: "example.com"} + require.NoError(t, conn.Create(&zone).Error) + routeID := uint(31) + boundDomain := ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &routeID, Domain: "api.example.com"} + unboundDomain := ZoneDomain{ZoneID: zone.ID, Domain: "www.example.com"} + require.NoError(t, conn.Create(&boundDomain).Error) + require.NoError(t, conn.Create(&unboundDomain).Error) + + domains, err := ListZoneDomainsByRouteID(ctx, routeID) + require.NoError(t, err) + require.Len(t, domains, 1) + require.Equal(t, boundDomain.ID, domains[0].ID) +}