feat(zone): add normalized zone domain schema

This commit is contained in:
ryan
2026-07-12 14:23:25 +08:00
parent 3eb670c674
commit 50678756d4
7 changed files with 319 additions and 32 deletions
+33 -32
View File
@@ -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 表名。
+115
View File
@@ -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
}
+88
View File
@@ -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)
}