[功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示

This commit is contained in:
ryan
2026-03-20 20:01:42 +08:00
parent edd31da527
commit afd891f0f6
26 changed files with 2046 additions and 105 deletions
+5
View File
@@ -9,6 +9,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
当前稳定能力包括:
* 反代规则管理
* 源站管理与复用
* 配置预览、发布、激活与回滚
* Agent 注册、心跳、同步、应用结果上报
* OpenResty 主配置模板、性能参数与缓存参数托管
@@ -40,6 +41,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
边界补充:
* OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品
* 源站对象只用于沉淀可复用的回源地址,不扩展为带健康检查、权重、策略编排的 `origin_pool`
* 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并
* 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管
@@ -105,6 +107,7 @@ Origin
当前有效实体:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `node_system_profiles`
@@ -120,6 +123,8 @@ Origin
稳定约束:
* 一个域名只对应一条 `proxy_routes` 规则
* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略
* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
+3
View File
@@ -99,6 +99,7 @@
当前有效实体:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `node_system_profiles`
@@ -115,7 +116,9 @@
通用约束:
* 不新增平台化对象,除非设计文档明确要求
* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes`
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
* `config_versions` 必须保存完整快照与渲染结果
+128
View File
@@ -0,0 +1,128 @@
package controller
import (
"encoding/json"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
func GetOrigins(c *gin.Context) {
origins, err := service.ListOrigins()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origins,
})
}
func GetOrigin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
origin, err := service.GetOriginDetail(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origin,
})
}
func CreateOrigin(c *gin.Context) {
var input service.OriginInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
origin, err := service.CreateOrigin(input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origin,
})
}
func UpdateOrigin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
var input service.OriginInput
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
origin, err := service.UpdateOrigin(uint(id), input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origin,
})
}
func DeleteOrigin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err = service.DeleteOrigin(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
@@ -4,7 +4,7 @@ import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 3
currentDatabaseSchemaVersion = 4
databaseSchemaVersionRowID = 1
)
+125 -1
View File
@@ -9,10 +9,13 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/schema"
"log/slog"
"net"
"net/url"
"openflare/common"
"openflare/utils/security"
"os"
"reflect"
"strings"
"sync"
)
@@ -36,6 +39,7 @@ func registeredModels() []any {
&File{},
&User{},
&Option{},
&Origin{},
&ProxyRoute{},
&ConfigVersion{},
&Node{},
@@ -307,6 +311,19 @@ func validateDatabaseSchemaV3(db *gorm.DB, backend string) error {
return nil
}
func validateDatabaseSchemaV4(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV3(db, backend); err != nil {
return err
}
if !db.Migrator().HasTable(&Origin{}) {
return fmt.Errorf("table origins is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
return fmt.Errorf("column proxy_routes.origin_id is missing")
}
return nil
}
func renameLegacyObservabilityShardTables(db *gorm.DB) error {
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
@@ -549,6 +566,91 @@ func migrateObservabilityShardsToID(db *gorm.DB, backend string) error {
return dropLegacyObservabilityShardTables(db)
}
func normalizeOriginAddressForMigration(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func extractOriginAddressForMigration(rawURL string) string {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
return normalizeOriginAddressForMigration(parsed.Hostname())
}
func backfillOriginsFromProxyRoutes(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&Origin{}) || !db.Migrator().HasTable(&ProxyRoute{}) {
return nil
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for origin backfill failed: %w", err)
}
type originSeed struct {
ID uint
Address string
}
originByAddress := make(map[string]originSeed)
var origins []Origin
if err := db.Order("id asc").Find(&origins).Error; err != nil {
return fmt.Errorf("list origins for backfill failed: %w", err)
}
for _, origin := range origins {
address := normalizeOriginAddressForMigration(origin.Address)
if address == "" {
continue
}
originByAddress[address] = originSeed{ID: origin.ID, Address: address}
}
for _, route := range routes {
address := extractOriginAddressForMigration(route.OriginURL)
if address == "" {
continue
}
origin, ok := originByAddress[address]
if !ok {
name := address
if ip := net.ParseIP(address); ip != nil {
name = ip.String()
}
record := Origin{
Name: name,
Address: address,
Remark: "",
}
if err := db.Create(&record).Error; err != nil {
return fmt.Errorf("create origin for address %s failed: %w", address, err)
}
origin = originSeed{ID: record.ID, Address: address}
originByAddress[address] = origin
}
if route.OriginID != nil && *route.OriginID == origin.ID {
continue
}
if err := db.Model(&ProxyRoute{}).
Where("id = ?", route.ID).
Update("origin_id", origin.ID).Error; err != nil {
return fmt.Errorf("backfill proxy route %d origin_id failed: %w", route.ID, err)
}
}
return nil
}
func migrateOriginsSchema(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
return backfillOriginsFromProxyRoutes(db)
}
func databaseSchemaMigrations() []databaseSchemaMigration {
return []databaseSchemaMigration{
{
@@ -563,6 +665,12 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
migrate: migrateObservabilityShardsToID,
validate: validateDatabaseSchemaV3,
},
{
fromVersion: 3,
toVersion: 4,
migrate: migrateOriginsSchema,
validate: validateDatabaseSchemaV4,
},
}
}
@@ -575,6 +683,19 @@ func databaseSchemaMigrationMap() map[int]databaseSchemaMigration {
}
func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error {
if backend == "sqlite" {
if err := migration.migrate(db, backend); err != nil {
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
}
if err := migration.validate(db, backend); err != nil {
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
}
if err := saveDatabaseSchemaVersion(db, migration.toVersion); err != nil {
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
}
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
if err := migration.migrate(tx, backend); err != nil {
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
@@ -614,10 +735,13 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := migrateSQLiteDataIfNeeded(db, backend); err != nil {
return err
}
if err := validateDatabaseSchemaV3(db, backend); err != nil {
if err := validateDatabaseSchemaV4(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
+55
View File
@@ -409,6 +409,61 @@ func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing.
}
}
func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-origins.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("applyCurrentSchema: %v", err)
}
now := time.Now().UTC()
route := &ProxyRoute{
Domain: "app.example.com",
OriginURL: "https://origin-a.internal:8443/api",
Upstreams: `["https://origin-a.internal:8443/api"]`,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(route).Error; err != nil {
t.Fatalf("seed proxy route: %v", err)
}
if err := db.Exec(`DELETE FROM origins`).Error; err != nil {
t.Fatalf("clear origins: %v", err)
}
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Update("origin_id", nil).Error; err != nil {
t.Fatalf("clear route origin_id: %v", err)
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
t.Fatalf("backfillOriginsFromProxyRoutes: %v", err)
}
if !db.Migrator().HasTable(&Origin{}) {
t.Fatal("expected origins table to exist")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
t.Fatal("expected proxy_routes.origin_id column to exist")
}
reloadedRoute := &ProxyRoute{}
if err := db.First(reloadedRoute, route.ID).Error; err != nil {
t.Fatalf("query proxy route: %v", err)
}
if reloadedRoute.OriginID == nil || *reloadedRoute.OriginID == 0 {
t.Fatal("expected migrated route to be linked to a backfilled origin")
}
origin := &Origin{}
if err := db.First(origin, *reloadedRoute.OriginID).Error; err != nil {
t.Fatalf("query origin: %v", err)
}
if origin.Address != "origin-a.internal" {
t.Fatalf("unexpected backfilled origin address: %s", origin.Address)
}
}
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
db := openBareTestSQLiteDB(t, "failed-validation.db")
+56
View File
@@ -0,0 +1,56 @@
package model
import "time"
type Origin struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Address string `json:"address" gorm:"uniqueIndex;size:255;not null"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OriginRouteCount struct {
OriginID uint `json:"origin_id"`
RouteCount int64 `json:"route_count"`
}
func ListOrigins() (origins []*Origin, err error) {
err = DB.Order("id desc").Find(&origins).Error
return origins, err
}
func GetOriginByID(id uint) (*Origin, error) {
origin := &Origin{}
err := DB.First(origin, id).Error
return origin, err
}
func GetOriginByAddress(address string) (*Origin, error) {
origin := &Origin{}
err := DB.Where("address = ?", address).First(origin).Error
return origin, err
}
func ListOriginRouteCounts() ([]OriginRouteCount, error) {
result := make([]OriginRouteCount, 0)
err := DB.Model(&ProxyRoute{}).
Select("origin_id, COUNT(*) AS route_count").
Where("origin_id IS NOT NULL").
Group("origin_id").
Scan(&result).Error
return result, err
}
func (origin *Origin) Insert() error {
return DB.Create(origin).Error
}
func (origin *Origin) Update() error {
return DB.Save(origin).Error
}
func (origin *Origin) Delete() error {
return DB.Delete(origin).Error
}
+7
View File
@@ -5,6 +5,7 @@ import "time"
type ProxyRoute struct {
ID uint `json:"id" gorm:"primaryKey"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
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:'[]'"`
@@ -37,6 +38,11 @@ func GetProxyRouteByID(id uint) (*ProxyRoute, error) {
return route, err
}
func ListProxyRoutesByOriginID(originID uint) (routes []*ProxyRoute, err error) {
err = DB.Where("origin_id = ?", originID).Order("id desc").Find(&routes).Error
return routes, err
}
func (route *ProxyRoute) Insert() error {
return DB.Create(route).Error
}
@@ -44,6 +50,7 @@ func (route *ProxyRoute) Insert() error {
func (route *ProxyRoute) Update() error {
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
"domain": route.Domain,
"origin_id": route.OriginID,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
+9
View File
@@ -82,6 +82,15 @@ func SetApiRouter(router *gin.Engine) {
proxyRoute.POST("/:id/update", controller.UpdateProxyRoute)
proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute)
}
originRoute := apiRouter.Group("/origins")
originRoute.Use(middleware.AdminAuth())
{
originRoute.GET("/", controller.GetOrigins)
originRoute.GET("/:id", controller.GetOrigin)
originRoute.POST("/", controller.CreateOrigin)
originRoute.POST("/:id/update", controller.UpdateOrigin)
originRoute.POST("/:id/delete", controller.DeleteOrigin)
}
managedDomainRoute := apiRouter.Group("/managed-domains")
managedDomainRoute.Use(middleware.AdminAuth())
{
+241
View File
@@ -0,0 +1,241 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"openflare/model"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
type OriginInput struct {
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
}
type OriginRouteSummary struct {
ID uint `json:"id"`
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
Enabled bool `json:"enabled"`
UpdatedAt string `json:"updated_at"`
}
type OriginView struct {
ID uint `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
RouteCount int64 `json:"route_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OriginDetailView struct {
OriginView
Routes []OriginRouteSummary `json:"routes"`
}
func ListOrigins() ([]OriginView, error) {
origins, err := model.ListOrigins()
if err != nil {
return nil, err
}
return buildOriginViews(origins)
}
func GetOriginDetail(id uint) (*OriginDetailView, error) {
origin, err := model.GetOriginByID(id)
if err != nil {
return nil, err
}
views, err := buildOriginViews([]*model.Origin{origin})
if err != nil {
return nil, err
}
routes, err := model.ListProxyRoutesByOriginID(id)
if err != nil {
return nil, err
}
items := make([]OriginRouteSummary, 0, len(routes))
for _, route := range routes {
items = append(items, OriginRouteSummary{
ID: route.ID,
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: route.Enabled,
UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
sort.Slice(items, func(i int, j int) bool {
return items[i].Domain < items[j].Domain
})
detail := &OriginDetailView{
OriginView: views[0],
Routes: items,
}
return detail, nil
}
func CreateOrigin(input OriginInput) (*model.Origin, error) {
origin, err := buildOrigin(nil, input)
if err != nil {
return nil, err
}
if err = origin.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("源站地址已存在")
}
return nil, err
}
return origin, nil
}
func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) {
origin, err := model.GetOriginByID(id)
if err != nil {
return nil, err
}
previousAddress := origin.Address
nextOrigin, err := buildOrigin(origin, input)
if err != nil {
return nil, err
}
err = model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(nextOrigin).Error; err != nil {
if isUniqueConstraintError(err) {
return errors.New("源站地址已存在")
}
return err
}
if previousAddress == nextOrigin.Address {
return nil
}
return updateRoutesForOriginAddress(tx, nextOrigin.ID, nextOrigin.Address)
})
if err != nil {
return nil, err
}
return nextOrigin, nil
}
func DeleteOrigin(id uint) error {
routes, err := model.ListProxyRoutesByOriginID(id)
if err != nil {
return err
}
if len(routes) > 0 {
return errors.New("该源站仍被规则引用,无法删除")
}
origin, err := model.GetOriginByID(id)
if err != nil {
return err
}
return origin.Delete()
}
func buildOrigin(existing *model.Origin, input OriginInput) (*model.Origin, error) {
address := normalizeOriginAddress(input.Address)
if err := validateOriginAddress(address); err != nil {
return nil, err
}
if existing == nil {
existing = &model.Origin{}
}
existing.Address = address
existing.Name = normalizeOriginName(input.Name, address)
existing.Remark = strings.TrimSpace(input.Remark)
return existing, nil
}
func getOrCreateOriginByAddress(address string) (*model.Origin, error) {
normalizedAddress := normalizeOriginAddress(address)
if err := validateOriginAddress(normalizedAddress); err != nil {
return nil, err
}
existing, err := model.GetOriginByAddress(normalizedAddress)
if err == nil {
return existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
origin := &model.Origin{
Name: normalizedAddress,
Address: normalizedAddress,
Remark: "",
}
if err := origin.Insert(); err != nil {
if isUniqueConstraintError(err) {
return model.GetOriginByAddress(normalizedAddress)
}
return nil, err
}
return origin, nil
}
func updateRoutesForOriginAddress(tx *gorm.DB, originID uint, address string) error {
var routes []*model.ProxyRoute
if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("query routes for origin update failed: %w", err)
}
for _, route := range routes {
rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address)
if err != nil {
return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err)
}
upstreams := make([]string, 0)
if strings.TrimSpace(route.Upstreams) != "" {
if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil {
return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err)
}
}
if len(upstreams) == 0 {
upstreams = append(upstreams, rewrittenOriginURL)
} else {
upstreams[0] = rewrittenOriginURL
}
upstreamsJSON, err := json.Marshal(upstreams)
if err != nil {
return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err)
}
if err := tx.Model(&model.ProxyRoute{}).
Where("id = ?", route.ID).
Updates(map[string]any{
"origin_url": rewrittenOriginURL,
"upstreams": string(upstreamsJSON),
}).Error; err != nil {
return fmt.Errorf("update route %d origin address failed: %w", route.ID, err)
}
}
return nil
}
func buildOriginViews(origins []*model.Origin) ([]OriginView, error) {
countRows, err := model.ListOriginRouteCounts()
if err != nil {
return nil, err
}
countMap := make(map[uint]int64, len(countRows))
for _, row := range countRows {
countMap[row.OriginID] = row.RouteCount
}
views := make([]OriginView, 0, len(origins))
for _, origin := range origins {
views = append(views, OriginView{
ID: origin.ID,
Name: origin.Name,
Address: origin.Address,
Remark: origin.Remark,
RouteCount: countMap[origin.ID],
CreatedAt: origin.CreatedAt,
UpdatedAt: origin.UpdatedAt,
})
}
return views, nil
}
+192
View File
@@ -0,0 +1,192 @@
package service
import (
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"unicode"
)
func normalizeOriginAddress(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func validateOriginAddress(address string) error {
if address == "" {
return errors.New("源站地址不能为空")
}
if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") {
return errors.New("源站地址格式不合法")
}
if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") {
return errors.New("源站地址无需包含 IPv6 方括号")
}
if ip := net.ParseIP(address); ip != nil {
return nil
}
if len(address) > 253 {
return errors.New("源站地址格式不合法")
}
labels := strings.Split(address, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return errors.New("源站地址格式不合法")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return errors.New("源站地址格式不合法")
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return errors.New("源站地址格式不合法")
}
}
return nil
}
func normalizeOriginName(name string, address string) string {
normalized := strings.TrimSpace(name)
if normalized != "" {
return normalized
}
return address
}
func normalizeOriginPort(raw string) (string, error) {
port := strings.TrimSpace(raw)
if port == "" {
return "", errors.New("端口不能为空")
}
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return "", errors.New("端口格式不合法")
}
return strconv.Itoa(value), nil
}
func normalizeOriginScheme(raw string) (string, error) {
scheme := strings.ToLower(strings.TrimSpace(raw))
switch scheme {
case "http", "https":
return scheme, nil
default:
return "", errors.New("源站协议仅支持 http 或 https")
}
}
func normalizeOriginURI(raw string) (string, error) {
uri := strings.TrimSpace(raw)
if uri == "" {
return "", nil
}
if strings.Contains(uri, "://") {
return "", errors.New("源站路径不能包含协议")
}
if !strings.HasPrefix(uri, "/") && !strings.HasPrefix(uri, "?") {
return "", errors.New("源站路径需以 / 或 ? 开头")
}
return uri, nil
}
func formatOriginHost(address string, port string) string {
if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") {
return net.JoinHostPort(address, port)
}
return net.JoinHostPort(address, port)
}
func buildOriginURLFromParts(
scheme string,
address string,
port string,
uri string,
) (string, error) {
normalizedScheme, err := normalizeOriginScheme(scheme)
if err != nil {
return "", err
}
normalizedAddress := normalizeOriginAddress(address)
if err := validateOriginAddress(normalizedAddress); err != nil {
return "", err
}
normalizedPort, err := normalizeOriginPort(port)
if err != nil {
return "", err
}
normalizedURI, err := normalizeOriginURI(uri)
if err != nil {
return "", err
}
parsed := &url.URL{
Scheme: normalizedScheme,
Host: formatOriginHost(normalizedAddress, normalizedPort),
}
if normalizedURI != "" {
if strings.HasPrefix(normalizedURI, "?") {
parsed.RawQuery = strings.TrimPrefix(normalizedURI, "?")
} else {
pathQuery := strings.SplitN(normalizedURI, "?", 2)
parsed.Path = pathQuery[0]
if len(pathQuery) > 1 {
parsed.RawQuery = pathQuery[1]
}
}
}
return parsed.String(), nil
}
func extractOriginAddress(rawURL string) (string, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("源站地址格式不合法: %w", err)
}
address := normalizeOriginAddress(parsed.Hostname())
if err := validateOriginAddress(address); err != nil {
return "", err
}
return address, nil
}
func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("源站地址格式不合法: %w", err)
}
address := normalizeOriginAddress(newAddress)
if err := validateOriginAddress(address); err != nil {
return "", err
}
port := parsed.Port()
if port == "" {
return "", errors.New("源站地址缺少端口")
}
parsed.Host = formatOriginHost(address, port)
return parsed.String(), nil
}
func splitOriginURL(rawURL string) (scheme string, address string, port string, uri string, err error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", "", "", "", err
}
scheme = parsed.Scheme
address = normalizeOriginAddress(parsed.Hostname())
port = parsed.Port()
uri = parsed.EscapedPath()
if uri == "" {
uri = parsed.Path
}
if parsed.RawQuery != "" {
if uri == "" {
uri = "?" + parsed.RawQuery
} else {
uri = uri + "?" + parsed.RawQuery
}
}
return scheme, address, port, uri, nil
}
+105
View File
@@ -0,0 +1,105 @@
package service
import (
"testing"
"openflare/model"
)
func TestCreateProxyRouteStructuredOriginAutoCreatesOrigin(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
OriginScheme: "https",
OriginAddress: "origin.internal",
OriginPort: "8443",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.OriginID == nil || *route.OriginID == 0 {
t.Fatal("expected route to be linked with an auto-created origin")
}
if route.OriginURL != "https://origin.internal:8443" {
t.Fatalf("unexpected route origin url: %s", route.OriginURL)
}
origin, err := model.GetOriginByID(*route.OriginID)
if err != nil {
t.Fatalf("GetOriginByID failed: %v", err)
}
if origin.Address != "origin.internal" {
t.Fatalf("unexpected origin address: %s", origin.Address)
}
}
func TestUpdateOriginRewritesLinkedRouteOriginURL(t *testing.T) {
setupServiceTestDB(t)
origin, err := CreateOrigin(OriginInput{
Name: "primary-origin",
Address: "origin-a.internal",
})
if err != nil {
t.Fatalf("CreateOrigin failed: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
OriginID: &origin.ID,
OriginScheme: "https",
OriginPort: "8443",
OriginURI: "/api",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
updatedOrigin, err := UpdateOrigin(origin.ID, OriginInput{
Name: origin.Name,
Address: "origin-c.internal",
})
if err != nil {
t.Fatalf("UpdateOrigin failed: %v", err)
}
if updatedOrigin.Address != "origin-c.internal" {
t.Fatalf("unexpected updated origin address: %s", updatedOrigin.Address)
}
reloadedRoute, err := model.GetProxyRouteByID(route.ID)
if err != nil {
t.Fatalf("GetProxyRouteByID failed: %v", err)
}
if reloadedRoute.OriginURL != "https://origin-c.internal:8443/api" {
t.Fatalf("expected route origin url to be rewritten, got %s", reloadedRoute.OriginURL)
}
if reloadedRoute.Upstreams == "" || reloadedRoute.Upstreams == "[]" {
t.Fatalf("expected route upstreams to be preserved, got %s", reloadedRoute.Upstreams)
}
}
func TestDeleteOriginRejectsReferencedOrigin(t *testing.T) {
setupServiceTestDB(t)
origin, err := CreateOrigin(OriginInput{
Address: "origin-a.internal",
})
if err != nil {
t.Fatalf("CreateOrigin failed: %v", err)
}
if _, err = CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
OriginID: &origin.ID,
OriginScheme: "https",
OriginPort: "443",
Enabled: true,
}); err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if err = DeleteOrigin(origin.ID); err == nil {
t.Fatal("expected referenced origin deletion to fail")
}
}
+84 -1
View File
@@ -7,6 +7,8 @@ import (
"openflare/model"
"regexp"
"strings"
"gorm.io/gorm"
)
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
@@ -25,7 +27,12 @@ type ProxyRouteCustomHeaderInput struct {
type ProxyRouteInput struct {
Domain string `json:"domain"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginScheme string `json:"origin_scheme"`
OriginAddress string `json:"origin_address"`
OriginPort string `json:"origin_port"`
OriginURI string `json:"origin_uri"`
OriginHost string `json:"origin_host"`
Upstreams []string `json:"upstreams"`
Enabled bool `json:"enabled"`
@@ -85,7 +92,10 @@ func DeleteProxyRoute(id uint) error {
func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) {
domain := strings.ToLower(strings.TrimSpace(input.Domain))
originURL := strings.TrimSpace(input.OriginURL)
originURL, originID, err := resolveProxyRoutePrimaryOrigin(input)
if err != nil {
return nil, err
}
originHost := strings.TrimSpace(input.OriginHost)
remark := strings.TrimSpace(input.Remark)
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
@@ -141,6 +151,7 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route = &model.ProxyRoute{}
}
route.Domain = domain
route.OriginID = originID
route.OriginURL = upstreams[0]
route.OriginHost = originHost
route.Upstreams = string(upstreamsJSON)
@@ -156,6 +167,78 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
return route, nil
}
func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) {
if hasStructuredOriginInput(input) {
scheme, err := normalizeOriginScheme(input.OriginScheme)
if err != nil {
return "", nil, err
}
port, err := normalizeOriginPort(input.OriginPort)
if err != nil {
return "", nil, err
}
uri, err := normalizeOriginURI(input.OriginURI)
if err != nil {
return "", nil, err
}
if input.OriginID != nil && *input.OriginID != 0 {
origin, err := model.GetOriginByID(*input.OriginID)
if err != nil {
return "", nil, errors.New("所选源站不存在")
}
originURL, err := buildOriginURLFromParts(
scheme,
origin.Address,
port,
uri,
)
if err != nil {
return "", nil, err
}
return originURL, &origin.ID, nil
}
address := normalizeOriginAddress(input.OriginAddress)
if err := validateOriginAddress(address); err != nil {
return "", nil, err
}
originURL, err := buildOriginURLFromParts(scheme, address, port, uri)
if err != nil {
return "", nil, err
}
origin, err := getOrCreateOriginByAddress(address)
if err != nil {
return "", nil, err
}
return originURL, &origin.ID, nil
}
originURL := strings.TrimSpace(input.OriginURL)
if originURL == "" {
return "", nil, errors.New("源站地址不能为空")
}
address, err := extractOriginAddress(originURL)
if err != nil {
return "", nil, err
}
origin, findErr := model.GetOriginByAddress(address)
if findErr == nil {
return originURL, &origin.ID, nil
}
if !errors.Is(findErr, gorm.ErrRecordNotFound) {
return "", nil, findErr
}
return originURL, nil, nil
}
func hasStructuredOriginInput(input ProxyRouteInput) bool {
return (input.OriginID != nil && *input.OriginID != 0) ||
strings.TrimSpace(input.OriginScheme) != "" ||
strings.TrimSpace(input.OriginAddress) != "" ||
strings.TrimSpace(input.OriginPort) != "" ||
strings.TrimSpace(input.OriginURI) != ""
}
func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) {
if len(headers) == 0 {
return []ProxyRouteCustomHeaderInput{}, nil
@@ -0,0 +1,11 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { OriginDetailPage } from '@/features/origins/components/origin-detail-page';
export default function OriginDetailRoute() {
const searchParams = useSearchParams();
return <OriginDetailPage originId={searchParams.get('id') ?? ''} />;
}
@@ -0,0 +1,5 @@
import { OriginsPage } from '@/features/origins/components/origins-page';
export default function OriginRoute() {
return <OriginsPage />;
}
@@ -47,6 +47,13 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
</svg>
);
case 'origin':
return (
<svg {...commonProps}>
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
<circle cx="12" cy="10.1" r="1.9" />
</svg>
);
case 'domain':
return (
<svg {...commonProps}>
@@ -0,0 +1,35 @@
import { apiRequest } from '@/lib/api/client';
import type {
OriginDetail,
OriginItem,
OriginMutationPayload,
} from '@/features/origins/types';
export function getOrigins() {
return apiRequest<OriginItem[]>('/origins/');
}
export function getOrigin(id: number) {
return apiRequest<OriginDetail>(`/origins/${id}`);
}
export function createOrigin(payload: OriginMutationPayload) {
return apiRequest<OriginItem>('/origins/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateOrigin(id: number, payload: OriginMutationPayload) {
return apiRequest<OriginItem>(`/origins/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteOrigin(id: number) {
return apiRequest<void>(`/origins/${id}/delete`, {
method: 'POST',
});
}
@@ -0,0 +1,221 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { deleteOrigin, getOrigin } from '@/features/origins/api/origins';
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
import {
DangerButton,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
export function OriginDetailPage({ originId }: { originId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{
tone: 'success' | 'danger';
message: string;
} | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const originQuery = useQuery({
queryKey: ['origins', originId],
queryFn: () => getOrigin(Number(originId)),
enabled: originId !== '',
});
const deleteMutation = useMutation({
mutationFn: deleteOrigin,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['origins'] });
router.push('/origin');
},
onError: (error) => {
setFeedback({
tone: 'danger',
message:
error instanceof Error ? error.message : '请求失败,请稍后重试。',
});
},
});
if (originQuery.isLoading) {
return <LoadingState />;
}
if (originQuery.isError) {
return (
<ErrorState
title="源站详情加载失败"
description={
originQuery.error instanceof Error
? originQuery.error.message
: '请求失败,请稍后重试。'
}
/>
);
}
const origin = originQuery.data;
if (!origin) {
return (
<EmptyState
title="源站不存在"
description="该源站可能已被删除,或当前 ID 无法匹配到源站记录。"
/>
);
}
const handleDelete = () => {
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
return;
}
deleteMutation.mutate(origin.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title={origin.name}
description="源站详情"
action={
<>
<Link
href="/origin"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回
</Link>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑源站
</SecondaryButton>
<DangerButton
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
删除源站
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="源站地址">
<p className="text-sm text-[var(--foreground-primary)]">
{origin.address}
</p>
</AppCard>
<AppCard title="绑定规则">
<div className="space-y-3">
<StatusBadge
label={`${origin.route_count} 条规则`}
variant={origin.route_count > 0 ? 'success' : 'warning'}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
编辑地址后,绑定规则的主源站地址会一起更新。
</p>
</div>
</AppCard>
<AppCard title="创建时间">
<p className="text-sm text-[var(--foreground-secondary)]">
{formatDateTime(origin.created_at)}
</p>
</AppCard>
<AppCard title="更新时间">
<p className="text-sm text-[var(--foreground-secondary)]">
{formatDateTime(origin.updated_at)}
</p>
</AppCard>
</div>
<AppCard title="备注">
<p className="text-sm text-[var(--foreground-secondary)]">
{origin.remark || '暂无备注'}
</p>
</AppCard>
<AppCard
title="关联规则"
description="展示当前源站作为主源站绑定的规则。"
>
{origin.routes.length === 0 ? (
<EmptyState
title="暂无关联规则"
description="当前源站还没有被任何规则引用。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">域名</th>
<th className="px-3 py-3 font-medium">源站地址</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">更新时间</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{origin.routes.map((route) => (
<tr key={route.id}>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{route.domain}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.origin_url}
</td>
<td className="px-3 py-4">
<StatusBadge
label={route.enabled ? '启用' : '停用'}
variant={route.enabled ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(route.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
{isEditorOpen ? (
<OriginEditorModal
isOpen={isEditorOpen}
origin={origin}
onClose={() => setIsEditorOpen(false)}
onSaved={() => {
setFeedback({ tone: 'success', message: '源站已更新。' });
void queryClient.invalidateQueries({
queryKey: ['origins', origin.id],
});
void queryClient.invalidateQueries({ queryKey: ['origins'] });
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,171 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppModal } from '@/components/ui/app-modal';
import { createOrigin, updateOrigin } from '@/features/origins/api/origins';
import type {
OriginItem,
OriginMutationPayload,
} from '@/features/origins/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const originSchema = z.object({
name: z.string().max(255, '源站名不能超过 255 个字符'),
address: z
.string()
.trim()
.min(1, '请输入源站地址')
.refine(
(value) => !/[/?#]/.test(value) && !value.includes('://'),
'源站地址格式不合法',
),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
type OriginFormValues = z.infer<typeof originSchema>;
function toPayload(values: OriginFormValues): OriginMutationPayload {
return {
name: values.name.trim(),
address: values.address.trim(),
remark: values.remark.trim(),
};
}
function toFormValues(origin?: OriginItem | null): OriginFormValues {
if (!origin) {
return {
name: '',
address: '',
remark: '',
};
}
return {
name: origin.name,
address: origin.address,
remark: origin.remark || '',
};
}
export function OriginEditorModal({
isOpen,
onClose,
origin,
onSaved,
}: {
isOpen: boolean;
onClose: () => void;
origin?: OriginItem | null;
onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void;
}) {
const queryClient = useQueryClient();
const form = useForm<OriginFormValues>({
resolver: zodResolver(originSchema),
defaultValues: toFormValues(origin),
});
useEffect(() => {
form.reset(toFormValues(origin));
}, [form, origin, isOpen]);
const mutation = useMutation({
mutationFn: async (values: OriginFormValues) => {
const payload = toPayload(values);
return origin ? updateOrigin(origin.id, payload) : createOrigin(payload);
},
onSuccess: async (savedOrigin) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['origins'] }),
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
]);
onSaved?.(savedOrigin, origin ? 'update' : 'create');
onClose();
},
});
const handleSubmit = form.handleSubmit((values) => {
mutation.mutate(values);
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={origin ? '编辑源站' : '新增源站'}
description="源站会作为规则里的可复用地址目录,协议和端口仍由规则决定。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="origin-editor-form"
disabled={mutation.isPending}
>
{mutation.isPending
? '保存中...'
: origin
? '保存修改'
: '新增源站'}
</PrimaryButton>
</div>
}
>
<form
id="origin-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="源站地址"
hint="支持 IP、域名或主机名,例如 10.0.0.10、origin.internal。"
error={form.formState.errors.address?.message}
>
<ResourceInput
placeholder="origin.internal"
{...form.register('address')}
/>
</ResourceField>
<ResourceField
label="源站名"
hint="可选,留空时默认使用源站地址。"
error={form.formState.errors.name?.message}
>
<ResourceInput placeholder="主站源站" {...form.register('name')} />
</ResourceField>
</div>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea
placeholder="例如:主站内网入口"
{...form.register('remark')}
/>
</ResourceField>
{mutation.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{mutation.error instanceof Error
? mutation.error.message
: '请求失败,请稍后重试。'}
</p>
) : null}
</form>
</AppModal>
);
}
@@ -0,0 +1,192 @@
'use client';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { deleteOrigin, getOrigins } from '@/features/origins/api/origins';
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
import type { OriginItem } from '@/features/origins/types';
import {
DangerButton,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
type FeedbackState = {
tone: 'success' | 'danger';
message: string;
};
export function OriginsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const originsQuery = useQuery({
queryKey: ['origins'],
queryFn: getOrigins,
});
const deleteMutation = useMutation({
mutationFn: deleteOrigin,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '源站已删除。' });
await queryClient.invalidateQueries({ queryKey: ['origins'] });
},
onError: (error) => {
setFeedback({
tone: 'danger',
message:
error instanceof Error ? error.message : '请求失败,请稍后重试。',
});
},
});
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
const handleDelete = (origin: OriginItem) => {
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
return;
}
setFeedback(null);
deleteMutation.mutate(origin.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="源站"
description="集中维护规则复用的源站地址,减少批量改地址时的重复操作。"
action={
<PrimaryButton
type="button"
onClick={() => {
setEditingOrigin(null);
setFeedback(null);
setIsEditorOpen(true);
}}
>
新增源站
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard
title="源站列表"
description="编辑源站地址后,所有引用该源站的规则会同步更新。"
>
{originsQuery.isLoading ? (
<LoadingState />
) : originsQuery.isError ? (
<ErrorState
title="源站列表加载失败"
description={
originsQuery.error instanceof Error
? originsQuery.error.message
: '请求失败,请稍后重试。'
}
/>
) : origins.length === 0 ? (
<EmptyState
title="暂无源站"
description="点击右上角“新增源站”开始录入。后续规则可直接复用这些地址。"
/>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{origins.map((origin) => (
<article
key={origin.id}
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{origin.name}
</h2>
<StatusBadge
label={`${origin.route_count} 条规则`}
variant={
origin.route_count > 0 ? 'success' : 'warning'
}
/>
</div>
<p className="text-sm text-[var(--foreground-primary)]">
{origin.address}
</p>
<p className="text-sm text-[var(--foreground-secondary)]">
{origin.remark || '暂无备注'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
最后更新:{formatDateTime(origin.updated_at)}
</div>
</div>
<div className="flex flex-row gap-2">
<Link
href={`/origin/detail?id=${origin.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
详情
</Link>
<SecondaryButton
type="button"
onClick={() => {
setEditingOrigin(origin);
setFeedback(null);
setIsEditorOpen(true);
}}
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(origin)}
disabled={deleteMutation.isPending}
>
删除
</DangerButton>
</div>
</div>
</article>
))}
</div>
)}
</AppCard>
</div>
{isEditorOpen ? (
<OriginEditorModal
isOpen={isEditorOpen}
origin={editingOrigin}
onClose={() => setIsEditorOpen(false)}
onSaved={(origin, mode) => {
setFeedback({
tone: 'success',
message: mode === 'create' ? '源站已创建。' : '源站已更新。',
});
setEditingOrigin(origin);
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,27 @@
export interface OriginItem {
id: number;
name: string;
address: string;
remark: string;
route_count: number;
created_at: string;
updated_at: string;
}
export interface OriginRouteSummary {
id: number;
domain: string;
origin_url: string;
enabled: boolean;
updated_at: string;
}
export interface OriginDetail extends OriginItem {
routes: OriginRouteSummary[];
}
export interface OriginMutationPayload {
name: string;
address: string;
remark: string;
}
@@ -20,6 +20,8 @@ import {
} from '@/features/config-versions/api/config-versions';
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import type { ManagedDomainItem } from '@/features/managed-domains/types';
import { getOrigins } from '@/features/origins/api/origins';
import type { OriginItem } from '@/features/origins/types';
import {
createProxyRoute,
deleteProxyRoute,
@@ -63,27 +65,17 @@ const cachePolicyValues = [
'path_prefix',
'path_exact',
] as const;
const originProtocolValues = ['http', 'https'] as const;
const proxyRouteSchema = z
.object({
managed_domain_id: z.string().trim().min(1, '请选择网站'),
subdomain_label: z.string(),
origin_url: z
.string()
.trim()
.min(1, '请输入源站地址')
.refine(
(value) => /^https?:\/\//.test(value),
'源站地址必须以 http:// 或 https:// 开头',
)
.refine((value) => {
try {
new URL(value);
return true;
} catch {
return false;
}
}, '请输入合法的源站地址'),
origin_id: z.string(),
origin_scheme: z.enum(originProtocolValues),
origin_address: z.string().trim().min(1, '请输入源站地址'),
origin_port: z.string().trim().min(1, '请输入端口'),
origin_uri: z.string(),
origin_host: z
.string()
.trim()
@@ -146,14 +138,70 @@ const proxyRouteSchema = z
});
}
const normalizedOriginAddress = value.origin_address.trim();
if (
normalizedOriginAddress &&
(/[/?#]/.test(normalizedOriginAddress) ||
normalizedOriginAddress.includes('://'))
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_address'],
message: '源站地址仅支持 IP、域名或主机名',
});
}
const normalizedOriginPort = value.origin_port.trim();
const portNumber = Number(normalizedOriginPort);
if (
normalizedOriginPort &&
(!/^\d+$/.test(normalizedOriginPort) ||
!Number.isInteger(portNumber) ||
portNumber < 1 ||
portNumber > 65535)
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_port'],
message: '端口需为 1 到 65535 的整数',
});
}
const normalizedOriginURI = value.origin_uri.trim();
if (
normalizedOriginURI &&
!normalizedOriginURI.startsWith('/') &&
!normalizedOriginURI.startsWith('?')
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_uri'],
message: '源站路径需以 / 或 ? 开头',
});
}
const primaryOriginURL = buildOriginUrl(
value.origin_scheme,
value.origin_address,
value.origin_port,
value.origin_uri,
);
if (!primaryOriginURL) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_address'],
message: '请输入完整的源站信息',
});
}
const upstreams = parseUpstreamsText(
value.origin_url,
primaryOriginURL,
value.upstreams_text,
);
if (upstreams.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_url'],
path: ['origin_address'],
message: '至少需要一个上游地址',
});
}
@@ -213,7 +261,11 @@ type FeedbackState = {
const defaultValues: ProxyRouteFormValues = {
managed_domain_id: '',
subdomain_label: '',
origin_url: '',
origin_id: '',
origin_scheme: 'https',
origin_address: '',
origin_port: '',
origin_uri: '',
origin_host: '',
upstreams_text: '',
enabled: true,
@@ -230,6 +282,7 @@ const defaultValues: ProxyRouteFormValues = {
const routesQueryKey = ['proxy-routes'];
const certificatesQueryKey = ['tls-certificates'];
const managedDomainsQueryKey = ['managed-domains'];
const originsQueryKey = ['origins'];
const versionsQueryKey = ['config-versions'];
function hasConfigChanges(diff: {
@@ -338,13 +391,66 @@ function buildCertificateLabel(certificate: TlsCertificateItem) {
: certificate.name;
}
function buildOriginUrl(
scheme: 'http' | 'https',
address: string,
port: string,
uri: string,
) {
const normalizedAddress = address.trim();
const normalizedPort = port.trim();
if (!normalizedAddress || !normalizedPort) {
return '';
}
const host =
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
? `[${normalizedAddress}]`
: normalizedAddress;
const normalizedURI = uri.trim();
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
}
function parseOriginUrl(rawValue: string) {
try {
const parsed = new URL(rawValue);
const uri = parsed.pathname === '/' ? '' : parsed.pathname;
return {
scheme: (parsed.protocol.replace(':', '') || 'https') as 'http' | 'https',
address: parsed.hostname,
port: parsed.port || (parsed.protocol === 'http:' ? '80' : '443'),
uri: parsed.search ? `${uri}${parsed.search}` || parsed.search : uri,
};
} catch {
return {
scheme: 'https' as const,
address: '',
port: '',
uri: '',
};
}
}
function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
const primaryOriginUrl = buildOriginUrl(
values.origin_scheme,
values.origin_address,
values.origin_port,
values.origin_uri,
);
return {
domain: buildRouteDomain(values.managed_domain_id, values.subdomain_label),
origin_url: values.origin_url.trim(),
origin_id: values.origin_id ? Number(values.origin_id) : null,
origin_url: primaryOriginUrl,
origin_scheme: values.origin_scheme,
origin_address: values.origin_address.trim(),
origin_port: values.origin_port.trim(),
origin_uri: values.origin_uri.trim(),
origin_host: values.origin_host.trim(),
upstreams: parseUpstreamsText(
values.origin_url,
primaryOriginUrl,
values.upstreams_text,
).slice(1),
enabled: values.enabled,
@@ -382,10 +488,16 @@ function toFormValues(
);
}
const parsedOrigin = parseOriginUrl(route.origin_url);
return {
managed_domain_id: managedDomainMatch.managedDomainId,
subdomain_label: managedDomainMatch.subdomainLabel,
origin_url: route.origin_url,
origin_id: route.origin_id ? String(route.origin_id) : '',
origin_scheme: parsedOrigin.scheme,
origin_address: parsedOrigin.address,
origin_port: parsedOrigin.port,
origin_uri: parsedOrigin.uri,
origin_host: route.origin_host || '',
upstreams_text: upstreams.slice(1).join('\n'),
enabled: route.enabled,
@@ -453,6 +565,22 @@ export function ProxyRoutesPage() {
control: form.control,
name: 'subdomain_label',
});
const watchedOriginAddress = useWatch({
control: form.control,
name: 'origin_address',
});
const watchedOriginScheme = useWatch({
control: form.control,
name: 'origin_scheme',
});
const watchedOriginPort = useWatch({
control: form.control,
name: 'origin_port',
});
const watchedOriginURI = useWatch({
control: form.control,
name: 'origin_uri',
});
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
const watchedEnableHttps = useWatch({
control: form.control,
@@ -487,6 +615,11 @@ export function ProxyRoutesPage() {
queryFn: getManagedDomains,
});
const originsQuery = useQuery({
queryKey: originsQueryKey,
queryFn: getOrigins,
});
const saveMutation = useMutation({
mutationFn: async (values: ProxyRouteFormValues) => {
const payload = toPayload(values);
@@ -506,6 +639,7 @@ export function ProxyRoutesPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }),
queryClient.invalidateQueries({ queryKey: originsQueryKey }),
]);
},
onError: (error) => {
@@ -561,6 +695,17 @@ export function ProxyRoutesPage() {
() => managedDomainsQuery.data ?? [],
[managedDomainsQuery.data],
);
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
const matchedOrigin = useMemo(
() =>
origins.find(
(item) =>
item.address.toLowerCase() ===
watchedOriginAddress.trim().toLowerCase(),
) ?? null,
[origins, watchedOriginAddress],
);
const selectedManagedDomain = useMemo(
() =>
@@ -578,6 +723,19 @@ export function ProxyRoutesPage() {
selectedManagedDomainValue,
watchedSubdomainLabel,
);
const primaryOriginPreview = buildOriginUrl(
watchedOriginScheme,
watchedOriginAddress,
watchedOriginPort,
watchedOriginURI,
);
useEffect(() => {
form.setValue('origin_id', matchedOrigin ? String(matchedOrigin.id) : '', {
shouldDirty: true,
shouldValidate: false,
});
}, [form, matchedOrigin]);
useEffect(() => {
if (!watchedEnableHttps) {
@@ -911,13 +1069,57 @@ export function ProxyRoutesPage() {
</ResourceSelect>
</ResourceField>
<ResourceField
label="源站地址"
hint="示例:https://origin.internal"
error={form.formState.errors.origin_url?.message}
label="源站协议"
hint="先选择回源协议,再选择或输入源站地址。"
>
<ResourceSelect
value={watchedOriginScheme}
onChange={(event) =>
form.setValue(
'origin_scheme',
event.target.value as ProxyRouteFormValues['origin_scheme'],
{
shouldDirty: true,
shouldValidate: true,
},
)
}
>
<option value="https">https</option>
<option value="http">http</option>
</ResourceSelect>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-[1.2fr_0.8fr]">
<ResourceField
label="源站"
hint="可从已有源站中选择,也可以直接输入新地址;保存规则后会自动创建不存在的源站。"
error={form.formState.errors.origin_address?.message}
>
<ResourceInput
placeholder="https://origin.internal"
{...form.register('origin_url')}
list="proxy-route-origin-addresses"
placeholder="origin.internal"
{...form.register('origin_address')}
/>
<datalist id="proxy-route-origin-addresses">
{origins.map((origin) => (
<option
key={origin.id}
value={origin.address}
label={origin.name}
/>
))}
</datalist>
</ResourceField>
<ResourceField
label="端口"
hint="规则主源站的访问端口。"
error={form.formState.errors.origin_port?.message}
>
<ResourceInput
placeholder="443"
{...form.register('origin_port')}
/>
</ResourceField>
</div>
@@ -958,6 +1160,32 @@ export function ProxyRoutesPage() {
)
) : null}
<div className="grid gap-4 md:grid-cols-[1fr_1fr]">
<ResourceField
label="源站路径"
hint="可选,用于保留已有带路径或查询参数的回源能力,例如 /api 或 ?token=demo。"
error={form.formState.errors.origin_uri?.message}
>
<ResourceInput
placeholder="/api"
{...form.register('origin_uri')}
/>
</ResourceField>
<AppCard
title="主源站预览"
description={
matchedOrigin
? `当前会复用已存在的源站 ${matchedOrigin.name}。`
: '如果这里的地址尚未收录,保存规则时会自动创建一个新源站。'
}
>
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
{primaryOriginPreview ||
'选择协议、输入源站和端口后,这里会显示最终主源站地址。'}
</p>
</AppCard>
</div>
<ResourceField
label="回源主机名"
hint="可选。填写后将覆盖回源请求的 Host,留空则默认使用访问域名 $host"
@@ -1,11 +1,12 @@
export interface ProxyRouteCustomHeader {
key: string;
value: string;
}
export interface ProxyRouteCustomHeader {
key: string;
value: string;
}
export interface ProxyRouteItem {
id: number;
domain: string;
origin_id: number | null;
origin_url: string;
origin_host: string;
upstreams: string;
@@ -21,10 +22,15 @@ export interface ProxyRouteItem {
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
domain: string;
export interface ProxyRouteMutationPayload {
domain: string;
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
origin_address: string;
origin_port: string;
origin_uri: string;
origin_host: string;
upstreams: string[];
enabled: boolean;
@@ -37,24 +43,24 @@ export interface ProxyRouteMutationPayload {
custom_headers: ProxyRouteCustomHeader[];
remark: string;
}
export interface TlsCertificateItem {
id: number;
name: string;
not_after?: string | null;
}
export interface ManagedDomainMatchCandidate {
managed_domain_id: number;
domain: string;
match_type: 'exact' | 'wildcard';
certificate_id: number;
certificate_name: string;
}
export interface ManagedDomainMatchResult {
domain: string;
matched: boolean;
candidate?: ManagedDomainMatchCandidate;
candidates: ManagedDomainMatchCandidate[];
}
export interface TlsCertificateItem {
id: number;
name: string;
not_after?: string | null;
}
export interface ManagedDomainMatchCandidate {
managed_domain_id: number;
domain: string;
match_type: 'exact' | 'wildcard';
certificate_id: number;
certificate_name: string;
}
export interface ManagedDomainMatchResult {
domain: string;
matched: boolean;
candidate?: ManagedDomainMatchCandidate;
candidates: ManagedDomainMatchCandidate[];
}
@@ -1,51 +1,56 @@
import type {NavigationItem} from '@/types/navigation';
import type { NavigationItem } from '@/types/navigation';
export const dashboardNavigation: NavigationItem[] = [
{
href: '/',
label: '总览',
icon: 'home',
},
{
href: '/node',
label: '节点',
icon: 'node',
},
{
href: '/website',
label: '网站',
icon: 'website',
},
{
href: '/proxy-route',
label: '规则',
icon: 'proxy',
},
{
href: '/config-version',
label: '发布',
icon: 'release',
},
{
href: '/access-log',
label: '日志',
icon: 'log',
},
{
href: '/',
label: '总览',
icon: 'home',
},
{
href: '/node',
label: '节点',
icon: 'node',
},
{
href: '/website',
label: '网站',
icon: 'website',
},
{
href: '/origin',
label: '源站',
icon: 'origin',
},
{
href: '/proxy-route',
label: '规则',
icon: 'proxy',
},
{
href: '/config-version',
label: '发布',
icon: 'release',
},
{
href: '/access-log',
label: '日志',
icon: 'log',
},
{
href: '/performance',
label: '性能',
icon: 'performance',
},
{
href: '/user',
label: '用户',
icon: 'user',
},
{
href: '/performance',
label: '性能',
icon: 'performance',
},
{
href: '/user',
label: '用户',
icon: 'user',
},
{
href: '/setting',
label: '设置',
icon: 'setting',
},
{
href: '/setting',
label: '设置',
icon: 'setting',
},
];
@@ -87,6 +87,18 @@ describe('ProxyRoutesPage', () => {
);
}
if (url.includes('/origins/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [],
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
@@ -166,6 +178,18 @@ describe('ProxyRoutesPage', () => {
);
}
if (url.includes('/origins/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [],
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
+1
View File
@@ -2,6 +2,7 @@ export type NavigationIconKey =
| 'home'
| 'node'
| 'website'
| 'origin'
| 'domain'
| 'certificate'
| 'proxy'