From 91643f6888e6a70d3013a6c689f0db0b88368b46 Mon Sep 17 00:00:00 2001 From: Rqzbeh Date: Sun, 7 Jun 2026 08:17:13 +0330 Subject: [PATCH] fix(postgres): make node traffic sync robust after public API inbound updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background NodeTrafficSyncJob (every 5s) started failing after a successful POST /panel/api/inbounds/update/{id} (including flows that inject streamSettings.externalProxy) with: node traffic sync: merge for failed: ERROR: CASE types boolean and integer cannot be matched (SQLSTATE 42804) Root cause: - The merge lives in setRemoteTrafficLocked (called from SetRemoteTraffic). - The client_traffics delta path used a dialect-sensitive expression: enable = enable AND ? last_online = GREATEST(last_online, ?) - On PostgreSQL, GREATEST / AND / COALESCE are implemented with internal CASE expressions. When "enable" columns (client_traffics, inbounds, ...) were INTEGER (common after SQLite → PG data migrations, older AutoMigrate, or mixed write paths) and the right-hand side was a boolean parameter (from snapshot ClientStats or form-bound API payload), PG rejected the expression at plan time. - The public API update path (unlike the internal remote wire path) always runs updateClientTraffics + UpdateClientStat + SyncInbound. This touches client_traffics.enable rows for any inbound that has clients. - SQLite tolerated 0/1 numeric bools; PG is strict. Fix: - Use an explicit CASE with ::boolean casts in the critical enable expression so the result type is always boolean. - Make GreatestExpr emit safe casts on Postgres. - Add a one-time normalization step in MigrationRequirements (runs on startup + xray restarts) that forces the relevant enable/enabled columns to boolean on Postgres using an idempotent DO block + USING cast. This cleans up pre-existing skew without a full re-migration. This branch is based on upstream/main (original mhsanaei/3x-ui main). The node traffic sync now survives arbitrary public-API inbound updates on PostgreSQL. --- database/dialect.go | 2 +- web/service/inbound.go | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/database/dialect.go b/database/dialect.go index bdf4486cf..3283dd28a 100644 --- a/database/dialect.go +++ b/database/dialect.go @@ -22,7 +22,7 @@ func JSONFieldText(expr, key string) string { func GreatestExpr(a, b string) string { if IsPostgres() { - return fmt.Sprintf("GREATEST(%s, %s)", a, b) + return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b) } return fmt.Sprintf("MAX(%s, %s)", a, b) } diff --git a/web/service/inbound.go b/web/service/inbound.go index b259953da..819126a56 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1809,7 +1809,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // from the node arriving after a central disable would otherwise // overwrite enable=false back to true, letting the client accumulate // far more traffic than their limit before being disabled again. - enableExpr := "enable AND ?" + // Use explicit CASE + boolean cast so the expression type is consistent + // on PostgreSQL (GREATEST/AND/CASE can surface "types boolean and integer" + // mismatches) even if the row was last touched via the public API update + // path or externalProxy-bearing inbounds. + enableExpr := "CASE WHEN ?::boolean THEN enable::boolean ELSE false END" if err := tx.Exec( fmt.Sprintf( `UPDATE client_traffics @@ -3524,6 +3528,39 @@ func (s *InboundService) MigrationRequirements() { } } + // Normalize "enable" columns to boolean on Postgres. Legacy SQLite data + // (0/1 integers), partial migrations, or mixed write paths (public API + // inbound updates that flow through UpdateClientStat + client syncs, plus + // node traffic merge deltas using enable AND/CASE) can leave the column as + // integer or with mixed interpretation. This prevents "CASE types boolean + // and integer cannot be matched" (internal for GREATEST/COALESCE/AND) in + // the node traffic sync merge (SetRemoteTraffic) and makes the sync robust + // even when inbounds are updated via /panel/api/inbounds/update (incl. ones + // carrying externalProxy in streamSettings). + if database.IsPostgres() { + // Use DO block so it is idempotent and doesn't fail if already boolean. + normalizeBool := func(table, col string) { + _, _ = tx.Exec(fmt.Sprintf(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = '%s' AND column_name = '%s' + AND data_type <> 'boolean' + ) THEN + ALTER TABLE %s ALTER COLUMN %s + TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END); + END IF; + END $$;`, table, col, table, col, col)) + } + normalizeBool("inbounds", "enable") + normalizeBool("client_traffics", "enable") + normalizeBool("nodes", "enable") + normalizeBool("clients", "enable") + normalizeBool("api_tokens", "enabled") + normalizeBool("outbound_subscriptions", "enabled") + } + // Fix inbounds based problems var inbounds []*model.Inbound err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error