From 3827d7d061a1e2cfbdc479be4cb355c3bb6bb288 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 19 May 2026 16:10:57 +0200 Subject: [PATCH] fix(clients): seed all clients when settings.clients has string tgId The ClientsTable seeder unmarshaled each settings.clients entry into model.Client and silently `continue`d on error. Older inbounds wrote tgId as an empty string for every client past the first; that fails to unmarshal into int64, so only the first client per inbound landed in the new clients table. Normalize tgId and the other int64/int fields on the raw map before marshal+unmarshal: parseable strings convert, empty/unparseable ones drop so the field falls back to zero. Also log on the residual unmarshal-failure path so the next regression is visible. Recover already-seeded installs by re-syncing each inbound's clients into the relational tables from MigrationRequirements, so running `x-ui migrate` heals partial seeds. --- database/db.go | 34 ++++++++++++++++++++++++++++++++++ web/service/inbound.go | 6 ++++++ 2 files changed, 40 insertions(+) diff --git a/database/db.go b/database/db.go index a06bb5c1..a5e2b66e 100644 --- a/database/db.go +++ b/database/db.go @@ -11,6 +11,7 @@ import ( "os" "path" "slices" + "strconv" "strings" "time" @@ -198,6 +199,36 @@ func runSeeders(isUsersEmpty bool) error { return nil } +// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw +// settings.clients entry so json.Unmarshal into model.Client doesn't fail +// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings +// drop the key so the field falls back to its zero value. +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 { @@ -226,12 +257,15 @@ func seedClientsFromInboundJSON() error { if !ok { continue } + normalizeClientJSONFields(obj) blob, err := json.Marshal(obj) if err != nil { continue } var c model.Client if err := json.Unmarshal(blob, &c); err != nil { + log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s", + inbound.Id, err, string(blob)) continue } email := strings.TrimSpace(c.Email) diff --git a/web/service/inbound.go b/web/service/inbound.go index 7fab7c48..07f8d37a 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -2924,6 +2924,12 @@ func (s *InboundService) MigrationRequirements() { } } } + + // Heal clients table for installs where the one-shot seeder + // skipped clients due to a tgId-string unmarshal error. + if syncErr := s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); syncErr != nil { + logger.Warning("MigrationRequirements sync clients failed:", syncErr) + } } tx.Save(inbounds)