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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 19:04:54 +02:00
parent 1f2769cebf
commit 719fd5086d
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 88 additions and 0 deletions

View file

@ -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 ""

View file

@ -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)
}
}