Browse Source

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.
MHSanaei 17 hours ago
parent
commit
3827d7d061
2 changed files with 40 additions and 0 deletions
  1. 34 0
      database/db.go
  2. 6 0
      web/service/inbound.go

+ 34 - 0
database/db.go

@@ -11,6 +11,7 @@ import (
 	"os"
 	"os"
 	"path"
 	"path"
 	"slices"
 	"slices"
+	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
@@ -198,6 +199,36 @@ func runSeeders(isUsersEmpty bool) error {
 	return nil
 	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 {
 func seedClientsFromInboundJSON() error {
 	var inbounds []model.Inbound
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {
 	if err := db.Find(&inbounds).Error; err != nil {
@@ -226,12 +257,15 @@ func seedClientsFromInboundJSON() error {
 				if !ok {
 				if !ok {
 					continue
 					continue
 				}
 				}
+				normalizeClientJSONFields(obj)
 				blob, err := json.Marshal(obj)
 				blob, err := json.Marshal(obj)
 				if err != nil {
 				if err != nil {
 					continue
 					continue
 				}
 				}
 				var c model.Client
 				var c model.Client
 				if err := json.Unmarshal(blob, &c); err != nil {
 				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
 					continue
 				}
 				}
 				email := strings.TrimSpace(c.Email)
 				email := strings.TrimSpace(c.Email)

+ 6 - 0
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)
 	tx.Save(inbounds)