diff --git a/internal/database/db.go b/internal/database/db.go index 2e5c99f83..16608c55b 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -2521,32 +2521,6 @@ func isAllowOnlyFinalRules(v any) bool { return true } -func normalizeClientJSONFields(obj map[string]any) { - normalizeInt := func(key string) { - raw, exists := obj[key] - if !exists { - return - } - s, ok := raw.(string) - if !ok { - return - } - trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "") - if trimmed == "" { - delete(obj, key) - return - } - if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil { - obj[key] = n - } else { - delete(obj, key) - } - } - for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} { - normalizeInt(k) - } -} - func seedClientsFromInboundJSON() error { var inbounds []model.Inbound if err := db.Find(&inbounds).Error; err != nil { @@ -2583,7 +2557,7 @@ func seedClientsFromInboundJSON() error { if !ok { continue } - normalizeClientJSONFields(obj) + model.NormalizeLegacyClientFields(obj) blob, err := json.Marshal(obj) if err != nil { continue diff --git a/internal/database/model/client_legacy_fields.go b/internal/database/model/client_legacy_fields.go new file mode 100644 index 000000000..a4bcfdc88 --- /dev/null +++ b/internal/database/model/client_legacy_fields.go @@ -0,0 +1,27 @@ +package model + +import ( + "strconv" + "strings" +) + +var legacyClientIntFields = []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} + +// NormalizeLegacyClientFields turns the string numbers older panels stored on a +// client object into integers; an empty or unparseable value is dropped. +func NormalizeLegacyClientFields(obj map[string]any) (changed bool) { + for _, key := range legacyClientIntFields { + s, ok := obj[key].(string) + if !ok { + continue + } + changed = true + trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "") + if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + obj[key] = n + } else { + delete(obj, key) + } + } + return changed +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 4ad687f4b..7b7e80c01 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1129,6 +1129,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo } inbound.Tag = tag + normalizeLegacyClientSettings(inbound) clients, err := s.GetClients(inbound) if err != nil { return inbound, false, err diff --git a/internal/web/service/inbound_import_legacy_fields_test.go b/internal/web/service/inbound_import_legacy_fields_test.go new file mode 100644 index 000000000..28da8407b --- /dev/null +++ b/internal/web/service/inbound_import_legacy_fields_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Exports from v2.x store tgId as a string ("" when unset); the stored copies +// are healed by a startup migration, but an imported export never passes it. +func TestAddInboundAcceptsLegacyStringClientFields(t *testing.T) { + setupConflictDB(t) + settings := `{"clients":[` + + `{"id":"11111111-1111-1111-1111-111111111111","email":"legacy-a","tgId":"","subId":"s-a","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"reset":0},` + + `{"id":"22222222-2222-2222-2222-222222222222","email":"legacy-b","tgId":"123456","subId":"s-b","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"reset":0}` + + `],"decryption":"none","fallbacks":[]}` + in := makeImportInbound("in-9201-tcp", 9201, settings, nil) + + saved, _, err := (&InboundService{}).AddInbound(in) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + + var rec model.ClientRecord + if err := database.GetDB().Where("email = ?", "legacy-b").First(&rec).Error; err != nil { + t.Fatalf("read client: %v", err) + } + if rec.TgID != 123456 { + t.Fatalf("client tgId = %d, want 123456 parsed from the string", rec.TgID) + } + var stored struct { + Clients []map[string]any `json:"clients"` + } + if err := json.Unmarshal([]byte(saved.Settings), &stored); err != nil { + t.Fatalf("stored settings: %v", err) + } + for _, c := range stored.Clients { + if _, isString := c["tgId"].(string); isString { + t.Fatalf("stored settings keep a string tgId for %v: %v", c["email"], c["tgId"]) + } + } +} diff --git a/internal/web/service/inbound_settings_clients.go b/internal/web/service/inbound_settings_clients.go index 1bdf4a214..7522e3cb4 100644 --- a/internal/web/service/inbound_settings_clients.go +++ b/internal/web/service/inbound_settings_clients.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "encoding/json" "strings" @@ -48,3 +49,27 @@ func settingsEntriesToClients(entries []any) ([]model.Client, error) { } return clients, nil } + +// normalizeLegacyClientSettings rewrites settings whose clients still carry the +// string numbers of a v2.x export, so parsing and storage see a single shape. +func normalizeLegacyClientSettings(inbound *model.Inbound) { + dec := json.NewDecoder(bytes.NewReader([]byte(inbound.Settings))) + dec.UseNumber() + var settings map[string]any + if err := dec.Decode(&settings); err != nil { + return + } + clients, _ := settings["clients"].([]any) + changed := false + for _, raw := range clients { + if obj, ok := raw.(map[string]any); ok && model.NormalizeLegacyClientFields(obj) { + changed = true + } + } + if !changed { + return + } + if out, err := json.MarshalIndent(settings, "", " "); err == nil { + inbound.Settings = string(out) + } +}