From 8f55d83b34110a7b64c30052cd653fd07d96eeca Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 9 Mar 2026 22:53:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BB=A3=E7=90=86=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=92=8C=E9=85=8D=E7=BD=AE=E7=89=88=E6=9C=AC=E7=9A=84?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8=E4=B8=8E=E6=9C=8D=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E5=BA=93=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84API=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atsf_server/common/init.go | 5 +- atsf_server/controller/config_version.go | 81 ++++++++++ atsf_server/controller/proxy_route.go | 103 ++++++++++++ atsf_server/model/config_version.go | 31 ++++ atsf_server/model/main.go | 8 + atsf_server/model/proxy_route.go | 41 +++++ atsf_server/router/api-router.go | 16 ++ atsf_server/router/api_phase1_test.go | 194 +++++++++++++++++++++++ atsf_server/service/config_version.go | 140 ++++++++++++++++ atsf_server/service/proxy_route.go | 103 ++++++++++++ 10 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 atsf_server/controller/config_version.go create mode 100644 atsf_server/controller/proxy_route.go create mode 100644 atsf_server/model/config_version.go create mode 100644 atsf_server/model/proxy_route.go create mode 100644 atsf_server/router/api_phase1_test.go create mode 100644 atsf_server/service/config_version.go create mode 100644 atsf_server/service/proxy_route.go diff --git a/atsf_server/common/init.go b/atsf_server/common/init.go index 15099523..50e7f05e 100644 --- a/atsf_server/common/init.go +++ b/atsf_server/common/init.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strings" ) var ( @@ -26,7 +27,9 @@ func printHelp() { } func init() { - flag.Parse() + if !strings.HasSuffix(os.Args[0], ".test") { + flag.Parse() + } if *PrintVersion { fmt.Println(Version) diff --git a/atsf_server/controller/config_version.go b/atsf_server/controller/config_version.go new file mode 100644 index 00000000..f5750c1a --- /dev/null +++ b/atsf_server/controller/config_version.go @@ -0,0 +1,81 @@ +package controller + +import ( + "gin-template/service" + "github.com/gin-gonic/gin" + "net/http" + "strconv" +) + +func GetConfigVersions(c *gin.Context) { + versions, err := service.ListConfigVersions() + 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": versions, + }) +} + +func GetActiveConfigVersion(c *gin.Context) { + version, err := service.GetActiveConfigVersion() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "当前没有激活版本", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": version, + }) +} + +func PublishConfigVersion(c *gin.Context) { + username := c.GetString("username") + result, err := service.PublishConfigVersion(username) + 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": result.Version, + }) +} + +func ActivateConfigVersion(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 + } + version, err := service.ActivateConfigVersion(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": version, + }) +} diff --git a/atsf_server/controller/proxy_route.go b/atsf_server/controller/proxy_route.go new file mode 100644 index 00000000..e5c273a9 --- /dev/null +++ b/atsf_server/controller/proxy_route.go @@ -0,0 +1,103 @@ +package controller + +import ( + "encoding/json" + "gin-template/service" + "github.com/gin-gonic/gin" + "net/http" + "strconv" +) + +func GetProxyRoutes(c *gin.Context) { + routes, err := service.ListProxyRoutes() + 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": routes, + }) +} + +func CreateProxyRoute(c *gin.Context) { + var input service.ProxyRouteInput + if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + route, err := service.CreateProxyRoute(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": route, + }) +} + +func UpdateProxyRoute(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.ProxyRouteInput + if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + route, err := service.UpdateProxyRoute(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": route, + }) +} + +func DeleteProxyRoute(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.DeleteProxyRoute(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": "", + }) +} diff --git a/atsf_server/model/config_version.go b/atsf_server/model/config_version.go new file mode 100644 index 00000000..0b8fb016 --- /dev/null +++ b/atsf_server/model/config_version.go @@ -0,0 +1,31 @@ +package model + +import "time" + +type ConfigVersion struct { + ID uint `json:"id" gorm:"primaryKey"` + Version string `json:"version" gorm:"uniqueIndex;size:32;not null"` + SnapshotJSON string `json:"snapshot_json" gorm:"type:text;not null"` + RenderedConfig string `json:"rendered_config" gorm:"type:text;not null"` + Checksum string `json:"checksum" gorm:"size:64;not null"` + IsActive bool `json:"is_active" gorm:"not null;default:false;index"` + CreatedBy string `json:"created_by" gorm:"size:64;not null"` + CreatedAt time.Time `json:"created_at"` +} + +func ListConfigVersions() (versions []*ConfigVersion, err error) { + err = DB.Order("id desc").Find(&versions).Error + return versions, err +} + +func GetConfigVersionByID(id uint) (*ConfigVersion, error) { + version := &ConfigVersion{} + err := DB.First(version, id).Error + return version, err +} + +func GetActiveConfigVersion() (*ConfigVersion, error) { + version := &ConfigVersion{} + err := DB.Where("is_active = ?", true).Order("id desc").First(version).Error + return version, err +} diff --git a/atsf_server/model/main.go b/atsf_server/model/main.go index 0a64651f..25e80ff6 100644 --- a/atsf_server/model/main.go +++ b/atsf_server/model/main.go @@ -64,6 +64,14 @@ func InitDB() (err error) { if err != nil { return err } + err = db.AutoMigrate(&ProxyRoute{}) + if err != nil { + return err + } + err = db.AutoMigrate(&ConfigVersion{}) + if err != nil { + return err + } err = createRootAccountIfNeed() return err } else { diff --git a/atsf_server/model/proxy_route.go b/atsf_server/model/proxy_route.go new file mode 100644 index 00000000..93140ef5 --- /dev/null +++ b/atsf_server/model/proxy_route.go @@ -0,0 +1,41 @@ +package model + +import "time" + +type ProxyRoute struct { + ID uint `json:"id" gorm:"primaryKey"` + Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` + OriginURL string `json:"origin_url" gorm:"size:2048;not null"` + Enabled bool `json:"enabled" gorm:"not null;default:true"` + Remark string `json:"remark" gorm:"size:255"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ListProxyRoutes() (routes []*ProxyRoute, err error) { + err = DB.Order("id desc").Find(&routes).Error + return routes, err +} + +func GetEnabledProxyRoutes() (routes []*ProxyRoute, err error) { + err = DB.Where("enabled = ?", true).Order("domain asc").Find(&routes).Error + return routes, err +} + +func GetProxyRouteByID(id uint) (*ProxyRoute, error) { + route := &ProxyRoute{} + err := DB.First(route, id).Error + return route, err +} + +func (route *ProxyRoute) Insert() error { + return DB.Create(route).Error +} + +func (route *ProxyRoute) Update() error { + return DB.Model(route).Select("domain", "origin_url", "enabled", "remark").Updates(route).Error +} + +func (route *ProxyRoute) Delete() error { + return DB.Delete(route).Error +} diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index e43665af..f1e93836 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -62,5 +62,21 @@ func SetApiRouter(router *gin.Engine) { fileRoute.POST("/", middleware.UploadRateLimit(), controller.UploadFile) fileRoute.DELETE("/:id", controller.DeleteFile) } + proxyRoute := apiRouter.Group("/proxy-routes") + proxyRoute.Use(middleware.AdminAuth()) + { + proxyRoute.GET("/", controller.GetProxyRoutes) + proxyRoute.POST("/", controller.CreateProxyRoute) + proxyRoute.PUT("/:id", controller.UpdateProxyRoute) + proxyRoute.DELETE("/:id", controller.DeleteProxyRoute) + } + configVersionRoute := apiRouter.Group("/config-versions") + configVersionRoute.Use(middleware.AdminAuth()) + { + configVersionRoute.GET("/", controller.GetConfigVersions) + configVersionRoute.GET("/active", controller.GetActiveConfigVersion) + configVersionRoute.POST("/publish", controller.PublishConfigVersion) + configVersionRoute.PUT("/:id/activate", controller.ActivateConfigVersion) + } } } diff --git a/atsf_server/router/api_phase1_test.go b/atsf_server/router/api_phase1_test.go new file mode 100644 index 00000000..1603bac5 --- /dev/null +++ b/atsf_server/router/api_phase1_test.go @@ -0,0 +1,194 @@ +package router_test + +import ( + "bytes" + "encoding/json" + "gin-template/common" + "gin-template/model" + "gin-template/router" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" +) + +type apiResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func TestPhase1PublishLifecycle(t *testing.T) { + gin.SetMode(gin.TestMode) + common.RedisEnabled = false + setupTestDB(t) + + engine := gin.New() + engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + router.SetApiRouter(engine) + + token := prepareRootToken(t) + + createBody := map[string]any{ + "domain": "app.example.com", + "origin_url": "https://origin-a.internal", + "enabled": true, + "remark": "primary route", + } + resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody) + var createdRoute model.ProxyRoute + decodeResponseData(t, resp, &createdRoute) + if createdRoute.Domain != "app.example.com" { + t.Fatalf("unexpected created route domain: %s", createdRoute.Domain) + } + + resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) + var routes []model.ProxyRoute + decodeResponseData(t, resp, &routes) + if len(routes) != 1 { + t.Fatalf("expected 1 route, got %d", len(routes)) + } + + resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) + var version1 model.ConfigVersion + decodeResponseData(t, resp, &version1) + if !version1.IsActive { + t.Fatal("expected published version to be active") + } + if version1.SnapshotJSON == "" || version1.RenderedConfig == "" || version1.Checksum == "" { + t.Fatal("expected published version to contain snapshot, rendered config and checksum") + } + + initialSnapshot := version1.SnapshotJSON + initialRendered := version1.RenderedConfig + + updateBody := map[string]any{ + "domain": "app.example.com", + "origin_url": "https://origin-b.internal", + "enabled": true, + "remark": "updated route", + } + routePath := "/api/proxy-routes/" + toString(createdRoute.ID) + resp = performJSONRequest(t, engine, token, http.MethodPut, routePath, updateBody) + decodeResponseData(t, resp, &createdRoute) + if createdRoute.OriginURL != "https://origin-b.internal" { + t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL) + } + + resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) + var version2 model.ConfigVersion + decodeResponseData(t, resp, &version2) + if version2.ID == version1.ID { + t.Fatal("expected a new version record") + } + + resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil) + var versions []model.ConfigVersion + decodeResponseData(t, resp, &versions) + if len(versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(versions)) + } + + activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil) + var activeVersion model.ConfigVersion + decodeResponseData(t, activeResp, &activeVersion) + if activeVersion.ID != version2.ID { + t.Fatalf("expected version %d active, got %d", version2.ID, activeVersion.ID) + } + + activatePath := "/api/config-versions/" + toString(version1.ID) + "/activate" + resp = performJSONRequest(t, engine, token, http.MethodPut, activatePath, nil) + decodeResponseData(t, resp, &activeVersion) + if activeVersion.ID != version1.ID || !activeVersion.IsActive { + t.Fatal("expected version1 to become active after rollback activation") + } + + var storedVersion1 model.ConfigVersion + if err := model.DB.First(&storedVersion1, version1.ID).Error; err != nil { + t.Fatalf("failed to query version1: %v", err) + } + if storedVersion1.SnapshotJSON != initialSnapshot { + t.Fatal("expected version1 snapshot to remain immutable") + } + if storedVersion1.RenderedConfig != initialRendered { + t.Fatal("expected version1 rendered config to remain immutable") + } + + deletePath := "/api/proxy-routes/" + toString(createdRoute.ID) + resp = performJSONRequest(t, engine, token, http.MethodDelete, deletePath, nil) + if !resp.Success { + t.Fatalf("expected delete route success, got: %s", resp.Message) + } +} + +func setupTestDB(t *testing.T) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "phase1.db") + common.SQLitePath = dbPath + if err := model.InitDB(); err != nil { + t.Fatalf("failed to init db: %v", err) + } + t.Cleanup(func() { + if err := model.CloseDB(); err != nil { + t.Fatalf("failed to close db: %v", err) + } + }) +} + +func prepareRootToken(t *testing.T) string { + t.Helper() + user := &model.User{Username: "root"} + if err := user.FillUserByUsername(); err != nil { + t.Fatalf("failed to load root user: %v", err) + } + user.Token = "phase1-test-token" + if err := model.DB.Model(user).Update("token", user.Token).Error; err != nil { + t.Fatalf("failed to set root token: %v", err) + } + return user.Token +} + +func performJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { + t.Helper() + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + } + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String()) + } + var resp apiResponse + if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if !resp.Success { + t.Fatalf("request %s %s failed: %s", method, path, resp.Message) + } + return resp +} + +func decodeResponseData(t *testing.T, resp apiResponse, target any) { + t.Helper() + if err := json.Unmarshal(resp.Data, target); err != nil { + t.Fatalf("failed to decode response data: %v", err) + } +} + +func toString(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} diff --git a/atsf_server/service/config_version.go b/atsf_server/service/config_version.go new file mode 100644 index 00000000..fef24051 --- /dev/null +++ b/atsf_server/service/config_version.go @@ -0,0 +1,140 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "gin-template/model" + "strings" + "time" + + "gorm.io/gorm" +) + +type ReleaseResult struct { + Version *model.ConfigVersion `json:"version"` + Routes []*model.ProxyRoute `json:"routes"` +} + +func ListConfigVersions() ([]*model.ConfigVersion, error) { + return model.ListConfigVersions() +} + +func GetActiveConfigVersion() (*model.ConfigVersion, error) { + return model.GetActiveConfigVersion() +} + +func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { + routes, err := model.GetEnabledProxyRoutes() + if err != nil { + return nil, err + } + if len(routes) == 0 { + return nil, errors.New("没有可发布的启用规则") + } + snapshotJSON, err := renderSnapshot(routes) + if err != nil { + return nil, err + } + renderedConfig := renderNginxConfig(routes) + version, err := nextVersionNumber(time.Now()) + if err != nil { + return nil, err + } + record := &model.ConfigVersion{ + Version: version, + SnapshotJSON: snapshotJSON, + RenderedConfig: renderedConfig, + Checksum: checksum(renderedConfig), + IsActive: true, + CreatedBy: createdBy, + } + err = model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil { + return err + } + if err := tx.Create(record).Error; err != nil { + return err + } + return nil + }) + if err != nil { + if isUniqueConstraintError(err) { + return nil, errors.New("版本号生成冲突,请重试") + } + return nil, err + } + return &ReleaseResult{ + Version: record, + Routes: routes, + }, nil +} + +func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { + version, err := model.GetConfigVersionByID(id) + if err != nil { + return nil, err + } + err = model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil { + return err + } + if err := tx.Model(version).Update("is_active", true).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + version.IsActive = true + return version, nil +} + +func renderSnapshot(routes []*model.ProxyRoute) (string, error) { + type snapshotRoute struct { + Domain string `json:"domain"` + OriginURL string `json:"origin_url"` + Enabled bool `json:"enabled"` + Remark string `json:"remark,omitempty"` + } + items := make([]snapshotRoute, 0, len(routes)) + for _, route := range routes { + items = append(items, snapshotRoute{ + Domain: route.Domain, + OriginURL: route.OriginURL, + Enabled: route.Enabled, + Remark: route.Remark, + }) + } + data, err := json.Marshal(items) + if err != nil { + return "", err + } + return string(data), nil +} + +func renderNginxConfig(routes []*model.ProxyRoute) string { + var builder strings.Builder + builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n") + for _, route := range routes { + builder.WriteString(fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_pass %s;\n }\n}\n\n", route.Domain, route.OriginURL)) + } + return builder.String() +} + +func checksum(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) +} + +func nextVersionNumber(now time.Time) (string, error) { + prefix := now.Format("20060102") + var count int64 + if err := model.DB.Model(&model.ConfigVersion{}).Where("version LIKE ?", prefix+"-%").Count(&count).Error; err != nil { + return "", err + } + return fmt.Sprintf("%s-%03d", prefix, count+1), nil +} diff --git a/atsf_server/service/proxy_route.go b/atsf_server/service/proxy_route.go new file mode 100644 index 00000000..810c97ca --- /dev/null +++ b/atsf_server/service/proxy_route.go @@ -0,0 +1,103 @@ +package service + +import ( + "errors" + "gin-template/model" + "net/url" + "strings" +) + +type ProxyRouteInput struct { + Domain string `json:"domain"` + OriginURL string `json:"origin_url"` + Enabled bool `json:"enabled"` + Remark string `json:"remark"` +} + +func ListProxyRoutes() ([]*model.ProxyRoute, error) { + return model.ListProxyRoutes() +} + +func CreateProxyRoute(input ProxyRouteInput) (*model.ProxyRoute, error) { + route, err := buildProxyRoute(nil, input) + if err != nil { + return nil, err + } + if err = route.Insert(); err != nil { + if isUniqueConstraintError(err) { + return nil, errors.New("域名已存在") + } + return nil, err + } + return route, nil +} + +func UpdateProxyRoute(id uint, input ProxyRouteInput) (*model.ProxyRoute, error) { + route, err := model.GetProxyRouteByID(id) + if err != nil { + return nil, err + } + route, err = buildProxyRoute(route, input) + if err != nil { + return nil, err + } + if err = route.Update(); err != nil { + if isUniqueConstraintError(err) { + return nil, errors.New("域名已存在") + } + return nil, err + } + return route, nil +} + +func DeleteProxyRoute(id uint) error { + route, err := model.GetProxyRouteByID(id) + if err != nil { + return err + } + return route.Delete() +} + +func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) { + domain := strings.ToLower(strings.TrimSpace(input.Domain)) + originURL := strings.TrimSpace(input.OriginURL) + remark := strings.TrimSpace(input.Remark) + if domain == "" { + return nil, errors.New("域名不能为空") + } + if strings.Contains(domain, "://") || strings.Contains(domain, "/") { + return nil, errors.New("域名格式不合法") + } + if err := validateOriginURL(originURL); err != nil { + return nil, err + } + if route == nil { + route = &model.ProxyRoute{} + } + route.Domain = domain + route.OriginURL = originURL + route.Enabled = input.Enabled + route.Remark = remark + return route, nil +} + +func validateOriginURL(raw string) error { + if raw == "" { + return errors.New("源站地址不能为空") + } + parsed, err := url.ParseRequestURI(raw) + if err != nil { + return errors.New("源站地址格式不合法") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.New("源站地址必须以 http:// 或 https:// 开头") + } + if parsed.Host == "" { + return errors.New("源站地址格式不合法") + } + return nil +} + +func isUniqueConstraintError(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") +}