From 38cfd7e2d94decaa2c185169955f996d9990dc37 Mon Sep 17 00:00:00 2001 From: Rain <94058511+Pluviobyte@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:37:53 +0800 Subject: [PATCH] fix(proxies): preserve omitted expiry and fallback settings --- .../internal/handler/admin/account_data.go | 8 +- backend/internal/handler/admin/proxy_data.go | 8 +- .../internal/handler/admin/proxy_handler.go | 30 +++---- .../admin/proxy_partial_update_test.go | 60 ++++++++++++++ backend/internal/service/admin_proxy.go | 40 +++++---- .../admin_proxy_partial_update_test.go | 81 +++++++++++++++++++ backend/internal/service/admin_service.go | 6 +- .../proxy_update_probe_invalidation_test.go | 3 +- 8 files changed, 199 insertions(+), 37 deletions(-) create mode 100644 backend/internal/handler/admin/proxy_partial_update_test.go create mode 100644 backend/internal/service/admin_proxy_partial_update_test.go diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index e44d726fd6..86c626b877 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -309,9 +309,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) _, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: existingExpiresAt, + ClearExpiresAt: existingExpiresAt == nil, FallbackMode: existingFallbackMode, BackupProxyID: existingBackupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: existingBackupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, Name: proxy.Name, Protocol: proxy.Protocol, Host: proxy.Host, @@ -383,9 +385,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) _, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: expiresAt, + ClearExpiresAt: expiresAt == nil, FallbackMode: fallbackMode, BackupProxyID: backupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: backupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, Name: created.Name, Protocol: created.Protocol, Host: created.Host, diff --git a/backend/internal/handler/admin/proxy_data.go b/backend/internal/handler/admin/proxy_data.go index 778208c68c..ea59e02ff4 100644 --- a/backend/internal/handler/admin/proxy_data.go +++ b/backend/internal/handler/admin/proxy_data.go @@ -172,9 +172,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) { updateInput := &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: existingExpiresAt, + ClearExpiresAt: existingExpiresAt == nil, FallbackMode: existingFallbackMode, BackupProxyID: existingBackupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: existingBackupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, // 保留已存在代理的网络配置字段 Name: existing.Name, Protocol: existing.Protocol, @@ -255,9 +257,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) { if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: expiresAt, + ClearExpiresAt: expiresAt == nil, FallbackMode: fallbackMode, BackupProxyID: backupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: backupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, Name: created.Name, Protocol: created.Protocol, Host: created.Host, diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 93c729107a..6c1961d944 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -41,17 +41,17 @@ type CreateProxyRequest struct { // UpdateProxyRequest represents update proxy request type UpdateProxyRequest struct { - Name string `json:"name"` - Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"` - Host string `json:"host"` - Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` - Status string `json:"status" binding:"omitempty,oneof=active inactive"` - ExpiresAt *int64 `json:"expires_at"` - FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"` - BackupProxyID *int64 `json:"backup_proxy_id"` - ExpiryWarnDays int `json:"expiry_warn_days" binding:"omitempty,min=0"` + Name string `json:"name"` + Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"` + Host string `json:"host"` + Port int `json:"port" binding:"omitempty,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` + ExpiresAt dto.NullableInt64Field `json:"expires_at"` + FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"` + BackupProxyID dto.NullableInt64Field `json:"backup_proxy_id"` + ExpiryWarnDays *int `json:"expiry_warn_days" binding:"omitempty,min=0"` } // List handles listing all proxies with pagination @@ -183,8 +183,8 @@ func (h *ProxyHandler) Update(c *gin.Context) { } var expiresAt *time.Time - if req.ExpiresAt != nil && *req.ExpiresAt > 0 { - t := time.Unix(*req.ExpiresAt, 0).UTC() + if req.ExpiresAt.Value != nil && *req.ExpiresAt.Value > 0 { + t := time.Unix(*req.ExpiresAt.Value, 0).UTC() expiresAt = &t } proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{ @@ -196,8 +196,10 @@ func (h *ProxyHandler) Update(c *gin.Context) { Password: strings.TrimSpace(req.Password), Status: strings.TrimSpace(req.Status), ExpiresAt: expiresAt, + ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil, FallbackMode: strings.TrimSpace(req.FallbackMode), - BackupProxyID: req.BackupProxyID, + BackupProxyID: req.BackupProxyID.Value, + ClearBackupID: req.BackupProxyID.Set && req.BackupProxyID.Value == nil, ExpiryWarnDays: req.ExpiryWarnDays, }) if err != nil { diff --git a/backend/internal/handler/admin/proxy_partial_update_test.go b/backend/internal/handler/admin/proxy_partial_update_test.go new file mode 100644 index 0000000000..4f81af575b --- /dev/null +++ b/backend/internal/handler/admin/proxy_partial_update_test.go @@ -0,0 +1,60 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type proxyPartialUpdateService struct { + service.AdminService + input *service.UpdateProxyInput +} + +func (s *proxyPartialUpdateService) UpdateProxy(_ context.Context, _ int64, input *service.UpdateProxyInput) (*service.Proxy, error) { + s.input = input + return &service.Proxy{ID: 9}, nil +} + +func TestProxyHandlerUpdatePreservesFieldPresence(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name, body string + set, clear, warn bool + }{ + {name: "omitted", body: `{"status":"inactive"}`}, + {name: "null clears", body: `{"expires_at":null,"backup_proxy_id":null,"fallback_mode":"none","expiry_warn_days":0}`, set: true, clear: true, warn: true}, + {name: "zero clears expiry", body: `{"expires_at":0,"backup_proxy_id":null}`, set: true, clear: true}, + {name: "values", body: `{"expires_at":1800000000,"backup_proxy_id":10,"fallback_mode":"proxy","expiry_warn_days":7}`, set: true, warn: true}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := &proxyPartialUpdateService{} + router := gin.New() + router.PUT("/proxies/:id", NewProxyHandler(svc).Update) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NotNil(t, svc.input) + require.Equal(t, tc.set, svc.input.ExpiresAt != nil || svc.input.ClearExpiresAt) + require.Equal(t, tc.set, svc.input.BackupProxyID != nil || svc.input.ClearBackupID) + require.Equal(t, tc.warn, svc.input.ExpiryWarnDays != nil) + if !tc.set || tc.clear { + require.Nil(t, svc.input.ExpiresAt) + require.Nil(t, svc.input.BackupProxyID) + } else { + require.Equal(t, int64(1800000000), svc.input.ExpiresAt.Unix()) + require.Equal(t, int64(10), *svc.input.BackupProxyID) + } + }) + } +} diff --git a/backend/internal/service/admin_proxy.go b/backend/internal/service/admin_proxy.go index 9b5a9223c7..25dfc952ac 100644 --- a/backend/internal/service/admin_proxy.go +++ b/backend/internal/service/admin_proxy.go @@ -95,24 +95,27 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd if input.BackupProxyID != nil && *input.BackupProxyID == id { return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") } - // 规范化 fallback_mode - mode := input.FallbackMode - if mode == "" { - mode = FallbackModeNone - } - // 校验:mode=proxy 必须有 backup - if mode == FallbackModeProxy && input.BackupProxyID == nil { - return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") - } - if input.ExpiryWarnDays < 0 { - return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") - } - proxy, err := s.proxyRepo.GetByID(ctx, id) if err != nil { return nil, err } + // Merge only supplied fields, then validate the resulting fallback configuration. + mode := proxy.FallbackMode + if input.FallbackMode != "" { + mode = input.FallbackMode + } + backupID := proxy.BackupProxyID + if input.BackupProxyID != nil || input.ClearBackupID { + backupID = input.BackupProxyID + } + if mode == FallbackModeProxy && backupID == nil { + return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") + } + if input.ExpiryWarnDays != nil && *input.ExpiryWarnDays < 0 { + return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") + } + if input.Name != "" { proxy.Name = input.Name } @@ -134,11 +137,14 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd if input.Status != "" { proxy.Status = input.Status } - // 透传有效期与回退字段 - proxy.ExpiresAt = input.ExpiresAt + if input.ExpiresAt != nil || input.ClearExpiresAt { + proxy.ExpiresAt = input.ExpiresAt + } proxy.FallbackMode = mode - proxy.BackupProxyID = input.BackupProxyID - proxy.ExpiryWarnDays = input.ExpiryWarnDays + proxy.BackupProxyID = backupID + if input.ExpiryWarnDays != nil { + proxy.ExpiryWarnDays = *input.ExpiryWarnDays + } if err := s.proxyRepo.Update(ctx, proxy); err != nil { return nil, err diff --git a/backend/internal/service/admin_proxy_partial_update_test.go b/backend/internal/service/admin_proxy_partial_update_test.go new file mode 100644 index 0000000000..78960c4901 --- /dev/null +++ b/backend/internal/service/admin_proxy_partial_update_test.go @@ -0,0 +1,81 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdminProxyPartialUpdatePreservesOmittedSettings(t *testing.T) { + for _, input := range []*UpdateProxyInput{{Status: "inactive"}, {Name: "renamed"}, {Host: "new.example"}} { + expiry := time.Now().Add(24 * time.Hour) + backup := int64(10) + original := &Proxy{ID: 9, Name: "original", Host: "old.example", Status: StatusActive, ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: 7} + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: original} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, input) + require.NoError(t, err) + require.Equal(t, original.ExpiresAt, got.ExpiresAt) + require.Equal(t, original.FallbackMode, got.FallbackMode) + require.Equal(t, original.BackupProxyID, got.BackupProxyID) + require.Equal(t, original.ExpiryWarnDays, got.ExpiryWarnDays) + require.Equal(t, 1, repo.updateCalls) + } +} + +func TestAdminProxyPartialUpdateClearsAndSetsSettings(t *testing.T) { + expiry := time.Now().Add(time.Hour) + backup := int64(10) + zero := 0 + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9, ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: 7}} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ + ClearExpiresAt: true, FallbackMode: FallbackModeNone, ClearBackupID: true, ExpiryWarnDays: &zero, + }) + require.NoError(t, err) + require.Nil(t, got.ExpiresAt) + require.Nil(t, got.BackupProxyID) + require.Equal(t, FallbackModeNone, got.FallbackMode) + require.Zero(t, got.ExpiryWarnDays) + days := 3 + got, err = svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ + ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: &days, + }) + require.NoError(t, err) + require.Equal(t, &expiry, got.ExpiresAt) + require.Equal(t, &backup, got.BackupProxyID) + require.Equal(t, FallbackModeProxy, got.FallbackMode) + require.Equal(t, days, got.ExpiryWarnDays) +} + +func TestAdminProxyPartialUpdateValidatesMergedFallback(t *testing.T) { + backup := int64(10) + self := int64(9) + negative := -1 + for _, tc := range []struct { + name string + input UpdateProxyInput + wantError bool + }{ + {name: "reuse existing backup", input: UpdateProxyInput{FallbackMode: FallbackModeProxy}}, + {name: "clear required backup", input: UpdateProxyInput{ClearBackupID: true}, wantError: true}, + {name: "self backup", input: UpdateProxyInput{BackupProxyID: &self}, wantError: true}, + {name: "negative warning", input: UpdateProxyInput{ExpiryWarnDays: &negative}, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9, FallbackMode: FallbackModeProxy, BackupProxyID: &backup}} + svc := &adminServiceImpl{proxyRepo: repo} + _, err := svc.UpdateProxy(context.Background(), 9, &tc.input) + if tc.wantError { + require.Error(t, err) + require.Zero(t, repo.updateCalls) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 47c45701fd..7a3644a597 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -534,6 +534,8 @@ type CreateProxyInput struct { ExpiryWarnDays int } +// UpdateProxyInput preserves omitted expiry/backup values; Clear flags explicitly +// remove them. A nil ExpiryWarnDays preserves the current warning period. type UpdateProxyInput struct { Name string Protocol string @@ -543,9 +545,11 @@ type UpdateProxyInput struct { Password string Status string ExpiresAt *time.Time + ClearExpiresAt bool FallbackMode string BackupProxyID *int64 - ExpiryWarnDays int + ClearBackupID bool + ExpiryWarnDays *int } type GenerateRedeemCodesInput struct { diff --git a/backend/internal/service/proxy_update_probe_invalidation_test.go b/backend/internal/service/proxy_update_probe_invalidation_test.go index ac5215e0e9..3568b503b6 100644 --- a/backend/internal/service/proxy_update_probe_invalidation_test.go +++ b/backend/internal/service/proxy_update_probe_invalidation_test.go @@ -57,11 +57,12 @@ func TestBothProxyUpdateServicesUseRepositoryUpdateBoundary(t *testing.T) { }, } svc := &adminServiceImpl{proxyRepo: repo} + warnDays := 7 _, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ Host: "new.example", FallbackMode: FallbackModeNone, - ExpiryWarnDays: 7, + ExpiryWarnDays: &warnDays, }) require.NoError(t, err)