Merge pull request #6811 from Pluviobyte/codex/fix-proxy-partial-update

fix(proxies): preserve omitted expiry and fallback settings
This commit is contained in:
Wesley Liddick
2026-09-08 20:10:20 +08:00
committed by GitHub
8 changed files with 199 additions and 37 deletions
@@ -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,
+6 -2
View File
@@ -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,
+16 -14
View File
@@ -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 {
@@ -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)
}
})
}
}
+23 -17
View File
@@ -101,24 +101,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
}
@@ -140,11 +143,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
@@ -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)
}
})
}
}
+5 -1
View File
@@ -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 {
@@ -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)