Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions internal/config/system_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"gpt-load/internal/db"
"gpt-load/internal/failover"
"gpt-load/internal/models"
"gpt-load/internal/store"
"gpt-load/internal/syncer"
Expand All @@ -21,6 +22,7 @@ import (
)

const SettingsUpdateChannel = "system_settings:updated"
const GroupScopedSettingKeyFailoverStatusCodes = "failover_status_codes"

// SystemSettingsManager 管理系统配置
type SystemSettingsManager struct {
Expand All @@ -32,6 +34,24 @@ func NewSystemSettingsManager() *SystemSettingsManager {
return &SystemSettingsManager{}
}

func sanitizeRuntimeSystemSettings(settings types.SystemSettings) types.SystemSettings {
settings.FailoverStatusCodes = ""
return settings
}

func IsSystemSettingVisible(key string) bool {
return key != GroupScopedSettingKeyFailoverStatusCodes
}

func ValidateSystemSettingsPayload(settingsMap map[string]any) error {
for key := range settingsMap {
if !IsSystemSettingVisible(key) {
return fmt.Errorf("setting key %s is only supported in group settings", key)
}
}
return nil
}

type groupManager interface {
Invalidate() error
}
Expand Down Expand Up @@ -74,6 +94,7 @@ func (sm *SystemSettingsManager) Initialize(store store.Store, gm groupManager,
}

settings.ProxyKeysMap = utils.StringToSet(settings.ProxyKeys, ",")
settings = sanitizeRuntimeSystemSettings(settings)

sm.DisplaySystemConfig(settings)

Expand Down Expand Up @@ -115,6 +136,10 @@ func (sm *SystemSettingsManager) EnsureSettingsInitialized(authConfig types.Auth
metadata := utils.GenerateSettingsMetadata(&defaultSettings)

for _, meta := range metadata {
if !IsSystemSettingVisible(meta.Key) {
continue
}

var existing models.SystemSetting
err := db.DB.Where("setting_key = ?", meta.Key).First(&existing).Error
if err != nil {
Expand Down Expand Up @@ -155,9 +180,9 @@ func (sm *SystemSettingsManager) EnsureSettingsInitialized(authConfig types.Auth
func (sm *SystemSettingsManager) GetSettings() types.SystemSettings {
if sm.syncer == nil {
logrus.Warn("SystemSettingsManager is not initialized, returning default settings.")
return utils.DefaultSystemSettings()
return sanitizeRuntimeSystemSettings(utils.DefaultSystemSettings())
}
return sm.syncer.Get()
return sanitizeRuntimeSystemSettings(sm.syncer.Get())
}

// GetAppUrl returns the effective App URL.
Expand All @@ -180,6 +205,10 @@ func (sm *SystemSettingsManager) GetAppUrl() string {

// UpdateSettings 更新系统配置
func (sm *SystemSettingsManager) UpdateSettings(settingsMap map[string]any) error {
if err := ValidateSystemSettingsPayload(settingsMap); err != nil {
return err
}

// 验证配置项
if err := sm.ValidateSettings(settingsMap); err != nil {
return err
Expand Down Expand Up @@ -307,6 +336,11 @@ func (sm *SystemSettingsManager) ValidateSettings(settingsMap map[string]any) er
}
}
}
if key == "failover_status_codes" {
if _, err := failover.ParseStatusCodeMatcher(strVal); err != nil {
return fmt.Errorf("invalid value for %s (%q): %w", key, strVal, err)
}
}
default:
return fmt.Errorf("unsupported type for setting key validation: %s", key)
}
Expand Down Expand Up @@ -377,6 +411,11 @@ func (sm *SystemSettingsManager) ValidateGroupConfigOverrides(configMap map[stri
}
}
}
if key == "failover_status_codes" {
if _, err := failover.ParseStatusCodeMatcher(strVal); err != nil {
return fmt.Errorf("invalid value for %s (%q): %w", key, strVal, err)
}
}
case reflect.Bool:
_, ok := value.(bool)
if !ok {
Expand Down
55 changes: 55 additions & 0 deletions internal/config/system_settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package config

import (
"strings"
"testing"

"gpt-load/internal/types"
)

func TestSanitizeRuntimeSystemSettings_ClearsSystemFailoverStatusCodes(t *testing.T) {
settings := types.SystemSettings{
ProxyKeys: "test-key",
FailoverStatusCodes: "404,250-260",
}

sanitized := sanitizeRuntimeSystemSettings(settings)

if sanitized.FailoverStatusCodes != "" {
t.Fatalf("expected system failover status codes to be cleared, got %q", sanitized.FailoverStatusCodes)
}
if sanitized.ProxyKeys != settings.ProxyKeys {
t.Fatalf("expected unrelated fields to remain unchanged")
}
}

func TestValidateSystemSettingsPayload_RejectsGroupScopedSetting(t *testing.T) {
err := ValidateSystemSettingsPayload(map[string]any{
GroupScopedSettingKeyFailoverStatusCodes: "404",
})
if err == nil {
t.Fatalf("expected validation error")
}
if !strings.Contains(err.Error(), GroupScopedSettingKeyFailoverStatusCodes) {
t.Fatalf("expected error to mention rejected key, got %v", err)
}
}

func TestValidateGroupConfigOverrides_FailoverStatusCodesErrorIncludesContext(t *testing.T) {
sm := NewSystemSettingsManager()

err := sm.ValidateGroupConfigOverrides(map[string]any{
GroupScopedSettingKeyFailoverStatusCodes: "404,abc",
})
if err == nil {
t.Fatalf("expected validation error")
}

errText := err.Error()
if !strings.Contains(errText, GroupScopedSettingKeyFailoverStatusCodes) {
t.Fatalf("expected error to include setting key, got %q", errText)
}
if !strings.Contains(errText, "404,abc") {
t.Fatalf("expected error to include original value, got %q", errText)
}
}
142 changes: 142 additions & 0 deletions internal/failover/status_code_matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package failover

import (
"fmt"
"sort"
"strconv"
"strings"
)

// StatusCodeRange represents an inclusive HTTP status code interval [Start, End].
type StatusCodeRange struct {
Start int
End int
}

// StatusCodeMatcher matches status codes against a compact, merged set of ranges.
// The zero value matches nothing.
type StatusCodeMatcher struct {
ranges []StatusCodeRange
}

// Match returns true if code is within any configured range.
func (m StatusCodeMatcher) Match(code int) bool {
if len(m.ranges) == 0 {
return false
}

// Find first range whose Start is greater than code, then check previous one.
i := sort.Search(len(m.ranges), func(i int) bool {
return m.ranges[i].Start > code
})
if i == 0 {
return false
}
r := m.ranges[i-1]
return code >= r.Start && code <= r.End
}

// IsEmpty reports whether the matcher has no ranges configured.
func (m StatusCodeMatcher) IsEmpty() bool {
return len(m.ranges) == 0
}

// ParseStatusCodeMatcher parses a comma-separated status code specification into a matcher.
//
// Spec grammar:
// - Single code: "404"
// - Inclusive range: "250-260"
// - Multiple entries separated by commas: "404,429,500-599"
//
// Whitespace around tokens and around "-" is allowed.
// Empty tokens are ignored.
func ParseStatusCodeMatcher(spec string) (StatusCodeMatcher, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return StatusCodeMatcher{}, nil
}

// Allow users to paste multi-line values (treat newline as comma).
spec = strings.ReplaceAll(spec, "\n", ",")

tokens := strings.Split(spec, ",")
parsed := make([]StatusCodeRange, 0, len(tokens))

for _, raw := range tokens {
token := strings.TrimSpace(raw)
if token == "" {
continue
}

if strings.Contains(token, "-") {
parts := strings.Split(token, "-")
if len(parts) != 2 {
return StatusCodeMatcher{}, fmt.Errorf("invalid status code range: %q", token)
}
startStr := strings.TrimSpace(parts[0])
endStr := strings.TrimSpace(parts[1])
if startStr == "" || endStr == "" {
return StatusCodeMatcher{}, fmt.Errorf("invalid status code range: %q", token)
}

start, err := strconv.Atoi(startStr)
if err != nil {
return StatusCodeMatcher{}, fmt.Errorf("invalid status code in range %q: %v", token, err)
}
end, err := strconv.Atoi(endStr)
if err != nil {
return StatusCodeMatcher{}, fmt.Errorf("invalid status code in range %q: %v", token, err)
}
if start > end {
return StatusCodeMatcher{}, fmt.Errorf("invalid status code range (start > end): %q", token)
}
if !isValidStatusCode(start) || !isValidStatusCode(end) {
return StatusCodeMatcher{}, fmt.Errorf("status code out of allowed range (100-999): %q", token)
}

parsed = append(parsed, StatusCodeRange{Start: start, End: end})
continue
}

code, err := strconv.Atoi(token)
if err != nil {
return StatusCodeMatcher{}, fmt.Errorf("invalid status code: %q", token)
}
if !isValidStatusCode(code) {
return StatusCodeMatcher{}, fmt.Errorf("status code out of allowed range (100-999): %q", token)
}
parsed = append(parsed, StatusCodeRange{Start: code, End: code})
}

if len(parsed) == 0 {
return StatusCodeMatcher{}, nil
}

sort.Slice(parsed, func(i, j int) bool {
if parsed[i].Start != parsed[j].Start {
return parsed[i].Start < parsed[j].Start
}
return parsed[i].End < parsed[j].End
})

merged := make([]StatusCodeRange, 0, len(parsed))
current := parsed[0]
for _, r := range parsed[1:] {
// Merge overlapping or adjacent ranges.
if r.Start <= current.End+1 {
if r.End > current.End {
current.End = r.End
}
continue
}
merged = append(merged, current)
current = r
}
merged = append(merged, current)

return StatusCodeMatcher{ranges: merged}, nil
}

func isValidStatusCode(code int) bool {
return code >= 100 && code <= 999
}
91 changes: 91 additions & 0 deletions internal/failover/status_code_matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package failover

import "testing"

func TestParseStatusCodeMatcher_Empty(t *testing.T) {
m, err := ParseStatusCodeMatcher("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !m.IsEmpty() {
t.Fatalf("expected empty matcher")
}
if m.Match(404) {
t.Fatalf("empty matcher should not match")
}
}

func TestParseStatusCodeMatcher_SingleCode(t *testing.T) {
m, err := ParseStatusCodeMatcher("404")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !m.Match(404) {
t.Fatalf("expected to match 404")
}
if m.Match(403) {
t.Fatalf("did not expect to match 403")
}
}

func TestParseStatusCodeMatcher_Range(t *testing.T) {
m, err := ParseStatusCodeMatcher("250-260")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, code := range []int{250, 255, 260} {
if !m.Match(code) {
t.Fatalf("expected to match %d", code)
}
}
for _, code := range []int{249, 261} {
if m.Match(code) {
t.Fatalf("did not expect to match %d", code)
}
}
}

func TestParseStatusCodeMatcher_MergeAdjacent(t *testing.T) {
m, err := ParseStatusCodeMatcher("250-260,261")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, code := range []int{250, 260, 261} {
if !m.Match(code) {
t.Fatalf("expected to match %d", code)
}
}
if m.Match(262) {
t.Fatalf("did not expect to match 262")
}
}

func TestParseStatusCodeMatcher_MergeOverlappingAndWhitespace(t *testing.T) {
m, err := ParseStatusCodeMatcher(" 404 , 500-550 , 540-599 ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !m.Match(404) || !m.Match(500) || !m.Match(575) || !m.Match(599) {
t.Fatalf("expected matches for configured codes")
}
if m.Match(400) || m.Match(600) {
t.Fatalf("did not expect matches outside configured codes")
}
}

func TestParseStatusCodeMatcher_InvalidSpecs(t *testing.T) {
cases := []string{
"abc",
"99",
"1000",
"250-",
"-260",
"260-250",
"250-260-270",
}
for _, c := range cases {
if _, err := ParseStatusCodeMatcher(c); err == nil {
t.Fatalf("expected error for %q", c)
}
}
}
Loading