1
0

2 Коммиты 33058c8eed ... 81611a6a76

Автор SHA1 Сообщение Дата
  Sanaei 81611a6a76 fix(node): flag every hosting node before a client edit applies 6 часов назад
  Sanaei 832423d543 fix(clients): flag the restart a partly-applied edit or delete still needs 7 часов назад

+ 26 - 0
docs/content/docs/en/reference/api/clients.mdx

@@ -570,6 +570,25 @@ _openapi:
           one per line. `limitHwid` is applied only when every inbound
           one per line. `limitHwid` is applied only when every inbound
           succeeded, so re-run the call after fixing the failure.
           succeeded, so re-run the call after fixing the failure.
         heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
         heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+      - content: 'The inbounds are applied concurrently and independently: one that
+          fails no longer stops the others. Every inbound error names the
+          inbound it came from (`inbound 7: <message>`), and several failures
+          are reported together, one per line. So a `success:false` response can
+          still have applied the edit to the remaining inbounds. The client
+          record is written after the inbounds, so a failure there is reported
+          without an `inbound <id>:` prefix and leaves the inbound edits in
+          place.'
+        heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
+      - content: 'The inbounds are applied concurrently and independently: one that
+          fails no longer stops the others. Every inbound error names the
+          inbound it came from (`inbound 7: <message>`), and several failures
+          are reported together, one per line. So a `success:false` response can
+          still have removed the client from the remaining inbounds; the client
+          record is kept in that case, so re-running the call retries exactly
+          the leftovers. The record and traffic rows are dropped after the
+          inbounds, so a failure there is reported without an `inbound <id>:`
+          prefix and leaves the client already removed from every inbound.'
+        heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
       - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
       - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
           instead of being given a fresh address, so the call fails with
           instead of being given a fresh address, so the call fails with
           `inbound <id>: wireguard: allowedIPs entry already used by another
           `inbound <id>: wireguard: allowedIPs entry already used by another
@@ -579,6 +598,13 @@ _openapi:
           independently, so the remaining ones are still attached and a
           independently, so the remaining ones are still attached and a
           `success:false` response can be partial.'
           `success:false` response can be partial.'
         heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
         heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
+      - content: 'The inbounds are applied concurrently and independently: one that
+          fails no longer stops the others. Every inbound error names the
+          inbound it came from (`inbound 7: <message>`), and several failures
+          are reported together, one per line. So a `success:false` response can
+          still have detached the remaining inbounds. Detach writes nothing
+          beyond the inbounds, so every error carries the prefix.'
+        heading: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
 ---
 ---
 
 
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}

+ 3 - 0
docs/public/openapi.json

@@ -7591,6 +7591,7 @@
         ],
         ],
         "summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
         "summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
         "operationId": "post_panel_api_clients_update_email",
         "operationId": "post_panel_api_clients_update_email",
+        "description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
         "parameters": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",
@@ -7654,6 +7655,7 @@
         ],
         ],
         "summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
         "summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
         "operationId": "post_panel_api_clients_del_email",
         "operationId": "post_panel_api_clients_del_email",
+        "description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
         "parameters": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",
@@ -7781,6 +7783,7 @@
         ],
         ],
         "summary": "Detach a client from one or more inbounds without deleting the client.",
         "summary": "Detach a client from one or more inbounds without deleting the client.",
         "operationId": "post_panel_api_clients_email_detach",
         "operationId": "post_panel_api_clients_email_detach",
+        "description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have detached the remaining inbounds. Detach writes nothing beyond the inbounds, so every error carries the prefix.",
         "parameters": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",

+ 3 - 0
frontend/public/openapi.json

@@ -7591,6 +7591,7 @@
         ],
         ],
         "summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
         "summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
         "operationId": "post_panel_api_clients_update_email",
         "operationId": "post_panel_api_clients_update_email",
+        "description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
         "parameters": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",
@@ -7654,6 +7655,7 @@
         ],
         ],
         "summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
         "summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
         "operationId": "post_panel_api_clients_del_email",
         "operationId": "post_panel_api_clients_del_email",
+        "description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
         "parameters": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",
@@ -7781,6 +7783,7 @@
         ],
         ],
         "summary": "Detach a client from one or more inbounds without deleting the client.",
         "summary": "Detach a client from one or more inbounds without deleting the client.",
         "operationId": "post_panel_api_clients_email_detach",
         "operationId": "post_panel_api_clients_email_detach",
+        "description": "The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have detached the remaining inbounds. Detach writes nothing beyond the inbounds, so every error carries the prefix.",
         "parameters": [
         "parameters": [
           {
           {
             "name": "email",
             "name": "email",

+ 6 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -1151,6 +1151,8 @@ export const sections: readonly Section[] = [
         path: '/panel/api/clients/update/:email',
         path: '/panel/api/clients/update/:email',
         summary:
         summary:
           'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
           'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
+        description:
+          'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
         params: [
         params: [
           {
           {
             name: 'email',
             name: 'email',
@@ -1167,6 +1169,8 @@ export const sections: readonly Section[] = [
         path: '/panel/api/clients/del/:email',
         path: '/panel/api/clients/del/:email',
         summary:
         summary:
           'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
           'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
+        description:
+          'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.',
         params: [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           {
           {
@@ -1200,6 +1204,8 @@ export const sections: readonly Section[] = [
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/:email/detach',
         path: '/panel/api/clients/:email/detach',
         summary: 'Detach a client from one or more inbounds without deleting the client.',
         summary: 'Detach a client from one or more inbounds without deleting the client.',
+        description:
+          'The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have detached the remaining inbounds. Detach writes nothing beyond the inbounds, so every error carries the prefix.',
         params: [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
           {
           {

+ 30 - 12
internal/web/controller/client.go

@@ -215,30 +215,42 @@ func (a *ClientController) update(c *gin.Context) {
 	}
 	}
 	inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
 	inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
 	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
 	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
+	// Flagged before the error check: a partly-applied edit leaves the change
+	// committed on the inbounds that succeeded, and those still need the restart.
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	// A partly-applied call committed real changes; a rejected one touched
+	// nothing, and broadcasting those would refetch every panel for nothing.
+	if needRestart || err == nil {
+		notifyClientsChanged()
+	}
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
 	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
 	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-	notifyClientsChanged()
 }
 }
 
 
 func (a *ClientController) delete(c *gin.Context) {
 func (a *ClientController) delete(c *gin.Context) {
 	email := c.Param("email")
 	email := c.Param("email")
 	keepTraffic := c.Query("keepTraffic") == "1"
 	keepTraffic := c.Query("keepTraffic") == "1"
 	needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
 	needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
+	// Flagged before the error check: a partly-applied delete already removed
+	// the client from the inbounds that succeeded, and those need the restart.
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	// A partly-applied call committed real removals; a rejected one touched
+	// nothing, and broadcasting those would refetch every panel for nothing.
+	if needRestart || err == nil {
+		notifyClientsChanged()
+	}
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-	notifyClientsChanged()
 }
 }
 
 
 type attachDetachBody struct {
 type attachDetachBody struct {
@@ -640,15 +652,21 @@ func (a *ClientController) detach(c *gin.Context) {
 		return
 		return
 	}
 	}
 	needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
 	needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
+	// Flagged before the error check: a partly-applied detach already removed
+	// the client from the inbounds that succeeded, and those need the restart.
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	// A partly-applied call committed real removals; a rejected one touched
+	// nothing, and broadcasting those would refetch every panel for nothing.
+	if needRestart || err == nil {
+		notifyClientsChanged()
+	}
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
 	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
 	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-	notifyClientsChanged()
 }
 }
 
 
 type bulkResetRequest struct {
 type bulkResetRequest struct {

+ 141 - 0
internal/web/controller/client_partial_apply_test.go

@@ -0,0 +1,141 @@
+package controller
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// seedPartlyApplyingClient puts one client on two inbounds and corrupts the second
+// one's settings, so a later op succeeds on one inbound and fails on the other.
+func seedPartlyApplyingClient(t *testing.T, email string, basePort int) (healthyID, brokenID int) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+	ids := make([]int, 0, 2)
+	for i := range 2 {
+		ib := &model.Inbound{
+			UserId: 1, Enable: true, Port: basePort + i,
+			Tag:      "in-" + string(rune('a'+i)) + "-partial",
+			Protocol: model.VLESS, Settings: `{"clients": []}`,
+			StreamSettings: `{"network":"tcp","security":"none"}`,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %d: %v", i, err)
+		}
+		ids = append(ids, ib.Id)
+	}
+
+	if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
+		Client:     model.Client{Email: email, ID: "11111111-2222-3333-4444-555555555555", SubID: "sub-" + email, Enable: true},
+		InboundIds: ids,
+	}); err != nil {
+		t.Fatalf("seed Create across both inbounds: %v", err)
+	}
+
+	if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
+		Update("settings", `{"clients":`).Error; err != nil {
+		t.Fatalf("corrupt inbound %d settings: %v", ids[1], err)
+	}
+	return ids[0], ids[1]
+}
+
+func postCtx(t *testing.T, email string, body any) (*gin.Context, *httptest.ResponseRecorder) {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+	w := httptest.NewRecorder()
+	c, _ := gin.CreateTestContext(w)
+	c.Params = gin.Params{{Key: "email", Value: email}}
+	payload := []byte("{}")
+	if body != nil {
+		var err error
+		if payload, err = json.Marshal(body); err != nil {
+			t.Fatalf("marshal body: %v", err)
+		}
+	}
+	c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload))
+	c.Request.Header.Set("Content-Type", "application/json")
+	return c, w
+}
+
+// assertPartialApply pins that the op really failed on one inbound, so a green
+// test cannot be a plain full success that never exercised the error path.
+func assertPartialApply(t *testing.T, w *httptest.ResponseRecorder) {
+	t.Helper()
+	var msg entity.Msg
+	if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
+		t.Fatalf("decode response %q: %v", w.Body.String(), err)
+	}
+	if msg.Success {
+		t.Fatalf("response reports success=true, want the partial apply to report failure: %q", w.Body.String())
+	}
+}
+
+// TestUpdateHandlerFlagsRestartOnPartialApply pins that an edit committed on some
+// inbounds and failed on others still flags Xray, as create/attach already did.
+func TestUpdateHandlerFlagsRestartOnPartialApply(t *testing.T) {
+	const email = "[email protected]"
+	seedPartlyApplyingClient(t, email, 43310)
+
+	a := &ClientController{}
+	a.xrayService.IsNeedRestartAndSetFalse()
+	c, w := postCtx(t, email, map[string]any{
+		"email": email, "id": "11111111-2222-3333-4444-555555555555",
+		"subId": "sub-" + email, "enable": true, "comment": "edited",
+	})
+	a.update(c)
+
+	assertPartialApply(t, w)
+	if !a.xrayService.IsNeedRestartAndSetFalse() {
+		t.Fatal("a partly-applied client edit left Xray unflagged for restart")
+	}
+}
+
+// TestDeleteHandlerFlagsRestartOnPartialApply is the delete-side twin: the
+// removals that landed still need the restart the error path used to discard.
+func TestDeleteHandlerFlagsRestartOnPartialApply(t *testing.T) {
+	const email = "[email protected]"
+	seedPartlyApplyingClient(t, email, 43320)
+
+	a := &ClientController{}
+	a.xrayService.IsNeedRestartAndSetFalse()
+	c, w := postCtx(t, email, nil)
+	a.delete(c)
+
+	assertPartialApply(t, w)
+	if !a.xrayService.IsNeedRestartAndSetFalse() {
+		t.Fatal("a partly-applied client delete left Xray unflagged for restart")
+	}
+}
+
+// TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
+func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
+	const email = "[email protected]"
+	healthyID, brokenID := seedPartlyApplyingClient(t, email, 43330)
+
+	a := &ClientController{}
+	a.xrayService.IsNeedRestartAndSetFalse()
+	c, w := postCtx(t, email, attachDetachBody{InboundIds: []int{healthyID, brokenID}})
+	a.detach(c)
+
+	assertPartialApply(t, w)
+	if !a.xrayService.IsNeedRestartAndSetFalse() {
+		t.Fatal("a partly-applied client detach left Xray unflagged for restart")
+	}
+}

+ 5 - 3
internal/web/job/ldap_sync_job.go

@@ -323,6 +323,11 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
 
 
 			for _, c := range batch {
 			for _, c := range batch {
 				nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
 				nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
+				// Read before the error check: the apply can report an error after
+				// it already committed, and that removal still needs the restart.
+				if nr {
+					restartNeeded = true
+				}
 				if err != nil {
 				if err != nil {
 					logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
 					logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
 						c.Email, ib.Id, ib.Tag, err)
 						c.Email, ib.Id, ib.Tag, err)
@@ -330,9 +335,6 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
 				}
 				}
 				logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
 				logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
 					c.Email, ib.Id, ib.Tag)
 					c.Email, ib.Id, ib.Tag)
-				if nr {
-					restartNeeded = true
-				}
 			}
 			}
 		}
 		}
 	}
 	}

+ 38 - 0
internal/web/service/client_crud.go

@@ -297,6 +297,36 @@ func (s *ClientService) fanoutInboundClientAdds(inboundSvc *InboundService, adds
 	return fanoutInboundApplies(applies)
 	return fanoutInboundApplies(applies)
 }
 }
 
 
+// markInboundNodesDirty makes a half-applied client edit unobservable to a node
+// snapshot merge, which skips a node whose config is already flagged dirty.
+func markInboundNodesDirty(inboundIds []int) error {
+	if len(inboundIds) == 0 {
+		return nil
+	}
+	var nodeIDs []int
+	for _, batch := range chunkInts(inboundIds, sqlInChunk) {
+		var ids []int
+		if err := database.GetDB().Model(&model.Inbound{}).
+			Where("id IN ? AND node_id IS NOT NULL", batch).
+			Distinct().Pluck("node_id", &ids).Error; err != nil {
+			return err
+		}
+		nodeIDs = append(nodeIDs, ids...)
+	}
+	if len(nodeIDs) == 0 {
+		return nil
+	}
+	return runSerializedTx(func(tx *gorm.DB) error {
+		svc := &NodeService{}
+		for _, id := range nodeIDs {
+			if err := svc.MarkNodeDirtyTx(tx, id); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
 func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
 func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
 	switch ib.Protocol {
 	switch ib.Protocol {
 	case model.VMESS, model.VLESS:
 	case model.VMESS, model.VLESS:
@@ -472,6 +502,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err != nil {
 	if err != nil {
 		return false, err
 		return false, err
 	}
 	}
+	// The rename rewrites the one shared client record, so every node holding
+	// this client goes stale — not just the ones an inboundIds filter applies.
+	attachedIds := append([]int(nil), inboundIds...)
 	if len(inboundFilter) > 0 {
 	if len(inboundFilter) > 0 {
 		allow := make(map[int]struct{}, len(inboundFilter))
 		allow := make(map[int]struct{}, len(inboundFilter))
 		for _, fid := range inboundFilter {
 		for _, fid := range inboundFilter {
@@ -603,6 +636,11 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 			return s.UpdateInboundClient(inboundSvc, data, existing.Email)
 			return s.UpdateInboundClient(inboundSvc, data, existing.Email)
 		}})
 		}})
 	}
 	}
+	// Each apply marks only its OWN node dirty, so between the first and last
+	// one a merge could resurrect the pre-edit email as a second client (#6050).
+	if err := markInboundNodesDirty(attachedIds); err != nil {
+		return false, err
+	}
 	needRestart, applyErr := fanoutInboundApplies(applies)
 	needRestart, applyErr := fanoutInboundApplies(applies)
 	if applyErr != nil {
 	if applyErr != nil {
 		return needRestart, applyErr
 		return needRestart, applyErr

+ 121 - 0
internal/web/service/client_edit_node_dirty_test.go

@@ -0,0 +1,121 @@
+package service
+
+import (
+	"context"
+	"sync/atomic"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// dirtyProbeRuntime reads a node's config_dirty flag at the moment an inbound
+// pushes, i.e. while the edit's other inbounds are still unapplied.
+type dirtyProbeRuntime struct {
+	fakeNodeRuntime
+	watch  int
+	sawSet atomic.Bool
+	probed atomic.Bool
+}
+
+func (d *dirtyProbeRuntime) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, c model.Client) error {
+	var node model.Node
+	if err := database.GetDB().Where("id = ?", d.watch).First(&node).Error; err == nil {
+		d.sawSet.Store(node.ConfigDirty)
+		d.probed.Store(true)
+	}
+	return d.fakeNodeRuntime.UpdateUser(ctx, ib, oldEmail, c)
+}
+
+// TestEditFlagsOtherNodesBeforeApplying pins the ordering: every node is flagged
+// BEFORE any inbound applies, so no merge can slot into the fanout gap.
+func TestEditFlagsOtherNodesBeforeApplying(t *testing.T) {
+	setupBulkDB(t)
+	startSerializedWriter(t)
+	mgr := useTestRuntimeManager(t)
+	db := database.GetDB()
+
+	const uuid = "eeeeeeee-1111-2222-3333-444444444444"
+	probe := &dirtyProbeRuntime{}
+	ids := fanoutNodeInbounds(t, mgr, probe, 2, 47200)
+
+	// The watched node is the one whose apply is made to fail, so nothing but
+	// the up-front marking can have flagged it when the other inbound pushes.
+	var victim model.Inbound
+	if err := db.Where("id = ?", ids[1]).First(&victim).Error; err != nil {
+		t.Fatalf("read inbound %d: %v", ids[1], err)
+	}
+	probe.watch = *victim.NodeID
+
+	if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
+		Client:     model.Client{Email: "carol", ID: uuid, SubID: "sub-carol", Enable: true},
+		InboundIds: ids,
+	}); err != nil {
+		t.Fatalf("seed Create: %v", err)
+	}
+	if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
+		Update("settings", `{"clients":`).Error; err != nil {
+		t.Fatalf("corrupt inbound %d: %v", ids[1], err)
+	}
+	if err := db.Model(model.Node{}).Where("1 = 1").
+		Update("config_dirty", false).Error; err != nil {
+		t.Fatalf("clear config_dirty: %v", err)
+	}
+
+	rec := lookupClientRecord(t, "carol")
+	if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, model.Client{
+		Email: "carol-renamed", ID: uuid, SubID: "sub-carol", Enable: true,
+	}, 0); err == nil {
+		t.Fatal("Update on a corrupted inbound should report the failure")
+	}
+
+	if !probe.probed.Load() {
+		t.Fatal("the healthy inbound never pushed, so the ordering was never observed")
+	}
+	if !probe.sawSet.Load() {
+		t.Fatal("a node was still clean while another inbound of the same edit was applying: a snapshot merge in that gap would resurrect the pre-edit email")
+	}
+}
+
+// TestEditFlagsNodesOutsideTheInboundFilter pins that the marking covers the
+// client's whole attachment set, not just the inbounds the filter applies.
+func TestEditFlagsNodesOutsideTheInboundFilter(t *testing.T) {
+	setupBulkDB(t)
+	startSerializedWriter(t)
+	mgr := useTestRuntimeManager(t)
+	db := database.GetDB()
+
+	const uuid = "ffffffff-1111-2222-3333-444444444444"
+	ids := fanoutNodeInbounds(t, mgr, &fakeNodeRuntime{}, 2, 47300)
+	if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
+		Client:     model.Client{Email: "dave", ID: uuid, SubID: "sub-dave", Enable: true},
+		InboundIds: ids,
+	}); err != nil {
+		t.Fatalf("seed Create: %v", err)
+	}
+	if err := db.Model(model.Node{}).Where("1 = 1").
+		Update("config_dirty", false).Error; err != nil {
+		t.Fatalf("clear config_dirty: %v", err)
+	}
+
+	// Edit scoped to the first inbound only; the second one's node still holds
+	// the old email once the shared record is renamed.
+	rec := lookupClientRecord(t, "dave")
+	if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, model.Client{
+		Email: "dave-renamed", ID: uuid, SubID: "sub-dave", Enable: true,
+	}, 0, ids[0]); err != nil {
+		t.Fatalf("filtered Update: %v", err)
+	}
+
+	var excluded model.Inbound
+	if err := db.Where("id = ?", ids[1]).First(&excluded).Error; err != nil {
+		t.Fatalf("read inbound %d: %v", ids[1], err)
+	}
+	var node model.Node
+	if err := db.Where("id = ?", *excluded.NodeID).First(&node).Error; err != nil {
+		t.Fatalf("read node %d: %v", *excluded.NodeID, err)
+	}
+	if !node.ConfigDirty {
+		t.Fatal("a node left out of the inboundIds filter stayed clean after the shared record was renamed, so its stale snapshot would be merged")
+	}
+}