From 719fd5086d29cab37f9f0934f2a8688a0722a895 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 17 May 2026 19:04:54 +0200 Subject: [PATCH] fix(clients): include inboundIds and traffic in /clients/list ClientRecord got its own MarshalJSON in the previous commit, and ClientWithAttachments embeds it to add inboundIds and traffic. Go promotes the embedded MarshalJSON to the outer struct, so the encoder was calling ClientRecord.MarshalJSON for the whole value and silently dropping the extras. The frontend reads row.inboundIds / row.traffic from /clients/list, so attached inbounds didn't render and newly added clients looked like they hadn't saved. Add an explicit MarshalJSON on ClientWithAttachments that splices the extras in. Co-Authored-By: Claude Opus 4.7 --- web/service/client.go | 29 +++++++++++++++++++ web/service/client_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 web/service/client_test.go diff --git a/web/service/client.go b/web/service/client.go index 1109da6c..9086f80b 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -24,6 +24,35 @@ type ClientWithAttachments struct { Traffic *xray.ClientTraffic `json:"traffic,omitempty"` } +// MarshalJSON is required because model.ClientRecord defines its own +// MarshalJSON. Go promotes the embedded method to the outer struct, so without +// this the encoder would call ClientRecord.MarshalJSON for the whole value and +// silently drop InboundIds and Traffic from the API response. +func (c ClientWithAttachments) MarshalJSON() ([]byte, error) { + rec, err := json.Marshal(c.ClientRecord) + if err != nil { + return nil, err + } + extras := struct { + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` + }{InboundIds: c.InboundIds, Traffic: c.Traffic} + extra, err := json.Marshal(extras) + if err != nil { + return nil, err + } + if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 { + return rec, nil + } + out := make([]byte, 0, len(rec)+len(extra)) + out = append(out, rec[:len(rec)-1]...) + if len(rec) > 2 { + out = append(out, ',') + } + out = append(out, extra[1:]...) + return out, nil +} + func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string { if rec == nil { return "" diff --git a/web/service/client_test.go b/web/service/client_test.go new file mode 100644 index 00000000..2cf8b219 --- /dev/null +++ b/web/service/client_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/xray" +) + +func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) { + c := ClientWithAttachments{ + ClientRecord: model.ClientRecord{Id: 1, Email: "alice@example.com"}, + InboundIds: []int{3, 5}, + Traffic: &xray.ClientTraffic{Email: "alice@example.com", Up: 1024, Down: 4096, Enable: true}, + } + out, err := json.Marshal(c) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if parsed["email"] != "alice@example.com" { + t.Errorf("expected ClientRecord fields to survive, got %v", parsed) + } + ids, ok := parsed["inboundIds"].([]any) + if !ok { + t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out) + } + if len(ids) != 2 { + t.Errorf("expected 2 inbound ids, got %d", len(ids)) + } + if _, ok := parsed["traffic"].(map[string]any); !ok { + t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"]) + } +} + +func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) { + c := ClientWithAttachments{ + ClientRecord: model.ClientRecord{Id: 1, Email: "bob@example.com"}, + InboundIds: nil, + } + out, err := json.Marshal(c) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(out, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if _, present := parsed["traffic"]; present { + t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"]) + } + if _, present := parsed["inboundIds"]; !present { + t.Errorf("expected inboundIds key to always be present, got %s", out) + } +}