Jelajahi Sumber

fix(node): show the activated first-use deadline on the Clients page

With "start after first use" on a node inbound, the node activates the
absolute deadline and the master adopts it into client_traffics via the
sync CASE merge — but the client record (what the Clients page reads) was
only refreshed by SyncInbound from the snapshot's settings JSON. A node
whose JSON still carried the negative duration (stale conversion, older
node build, or a mixed local+node attachment) kept rewriting the record
back to "not started" even though the DB held the real deadline (#5714).

Lift the activated deadline from client_traffics onto still-negative
client records at the end of every node sync, after SyncInbound has run.
Intentional resets back to delayed start are unaffected: editing a client
also resets client_traffics to the negative duration, so the lift's
expiry_time > 0 guard never matches.

Closes #5714
MHSanaei 2 hari lalu
induk
melakukan
8dd3b31ee8

+ 15 - 0
internal/web/service/inbound_node.go

@@ -191,6 +191,17 @@ func mergeActivationExpiry(existing, node int64) int64 {
 	return node
 }
 
+// liftActivatedClientRecordExpiries copies a node-activated deadline from
+// client_traffics onto client records still holding the negative duration (#5714).
+func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
+	return tx.Exec(
+		`UPDATE clients
+		 SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1)
+		 WHERE clients.expiry_time < 0
+		   AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`,
+	).Error
+}
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
@@ -847,6 +858,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 	}
 
+	if err := liftActivatedClientRecordExpiries(tx); err != nil {
+		logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
+	}
+
 	if err := tx.Commit().Error; err != nil {
 		return false, err
 	}

+ 42 - 0
internal/web/service/node_client_expiry_sync_test.go

@@ -3,6 +3,7 @@ package service
 import (
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
@@ -139,3 +140,44 @@ func TestNodeRenewExtendsExpiry(t *testing.T) {
 		t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
 	}
 }
+
+// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
+// the deadline (positive ClientStats) while its settings JSON still carries the
+// negative duration, so SyncInbound keeps writing the stale value into the
+// client record and the Clients page shows "not started" forever.
+func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
+	svc := &InboundService{}
+
+	const email = "delayed"
+	const duration = int64(-2592000000)
+	const activated = int64(1798448344010)
+	negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
+
+	if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
+		t.Fatalf("seed client record: %v", err)
+	}
+
+	readRecordExpiry := func() int64 {
+		t.Helper()
+		var rec model.ClientRecord
+		if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
+			t.Fatalf("read client record: %v", err)
+		}
+		return rec.ExpiryTime
+	}
+
+	syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
+	if got := readRecordExpiry(); got != duration {
+		t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
+	}
+
+	syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
+	}
+	if got := readRecordExpiry(); got != activated {
+		t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
+	}
+}