mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #6811 from Pluviobyte/codex/fix-proxy-partial-update
fix(proxies): preserve omitted expiry and fallback settings
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user