From 6af2995930fa36aa145e6304f3ab55059f47d70a Mon Sep 17 00:00:00 2001 From: Kim Fom <57032138+kimfom01@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:38:44 +0100 Subject: [PATCH] feat(api): add GET endpoint to look up clients by Telegram ID (#5945) * feat(api): add GET endpoint to look up clients by Telegram ID GET /panel/api/clients/getByTgId/:tgId returns all clients matching the given Telegram user ID. tgId is not unique, so the response is an array of {client, inboundIds, externalLinks, usedTraffic} objects. * fix: guard tgId=0 sentinel, index tg_id, deduplicate enrichment in getByTgId Three issues from the code review on the new GET /panel/api/clients/getByTgId/:tgId endpoint: the lookup did not short-circuit tgId <= 0 (this codebase's sentinel for 'no Telegram ID'), had no index on clients.tg_id causing a full table scan on every call, and duplicated the per-record enrichment (inbound IDs, external links, effective flow, traffic) identically between get and getByTgId. - Reject tgId <= 0 in GetRecordsByTgId with a clear error, matching the '0 = none' convention used elsewhere in the codebase. - Add index:idx_clients_tg_id to ClientRecord.TgID (struct tag + idempotent startup migration for existing databases). - Extract buildClientPayload helper used by both get and getByTgId. - Update client_lookup_test.go to verify sentinel rejection instead of expecting tgId=0 to be a valid lookup. * refactor(api): move Telegram client lookup under /get/tgId/:tgId Nest the Telegram-ID lookup beside the email lookup as /get/tgId/:tgId instead of the flat /getByTgId/:tgId, so both client fetch routes share the /get prefix. Gin resolves the static tgId segment ahead of the :email wildcard, so /get/:email keeps matching plain email lookups, including a literal 'tgId' email. The endpoint is unreleased, so no compatibility concern. --- frontend/public/openapi.json | 41 +++++++++++ frontend/src/pages/api-docs/endpoints.ts | 10 +++ internal/database/db.go | 14 ++++ internal/database/model/model.go | 2 +- internal/web/controller/client.go | 55 ++++++++++++--- internal/web/service/client_lookup.go | 10 +++ internal/web/service/client_lookup_test.go | 80 ++++++++++++++++++++++ 7 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 internal/web/service/client_lookup_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 386323ae1..1e280f13e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -5824,6 +5824,47 @@ } } }, + "/panel/api/clients/get/tgId/{tgId}": { + "get": { + "tags": [ + "Clients" + ], + "summary": "Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.", + "operationId": "get_panel_api_clients_get_tgId_tgId", + "parameters": [ + { + "name": "tgId", + "in": "path", + "required": true, + "description": "Telegram user ID (numeric).", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/clients/add": { "post": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1d2c3c3b9..db4b3bced 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -582,6 +582,16 @@ export const sections: readonly Section[] = [ response: '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}', }, + { + method: 'GET', + path: '/panel/api/clients/get/tgId/:tgId', + summary: 'Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.', + params: [ + { name: 'tgId', in: 'path', type: 'integer', desc: 'Telegram user ID (numeric).' }, + ], + response: + '{\n "success": true,\n "obj": [\n {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [],\n "usedTraffic": 1048576\n }\n ]\n}', + }, { method: 'POST', path: '/panel/api/clients/add', diff --git a/internal/database/db.go b/internal/database/db.go index fd8dba844..b02c1eae1 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -131,6 +131,9 @@ func initModels() error { if err := migrateVmessRemovedSecurities(); err != nil { return err } + if err := migrateTgIDIndex(); err != nil { + return err + } if IsPostgres() { if err := resyncPostgresSequences(db, models); err != nil { log.Printf("Error resyncing postgres sequences: %v", err) @@ -884,6 +887,17 @@ func migrateVmessRemovedSecurities() error { return nil } +// migrateTgIDIndex creates an index on the clients.tg_id column so that +// lookups by Telegram ID do not require a full table scan. The index tag +// on the struct field already causes AutoMigrate to create it on new +// installations; the explicit migration ensures existing databases get it. +func migrateTgIDIndex() error { + if db.Migrator().HasIndex(&model.ClientRecord{}, "idx_clients_tg_id") { + return nil + } + return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID") +} + // normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based // minimum (rows written by builds that defaulted the column to 0, or by nodes // predating the field) so they cannot sort ahead of explicitly ranked inbounds. diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 6ae244607..6c650fbfa 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -904,7 +904,7 @@ type ClientRecord struct { TotalGB int64 `json:"totalGB" gorm:"column:total_gb"` ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"` Enable bool `json:"enable" gorm:"default:true"` - TgID int64 `json:"tgId" gorm:"column:tg_id"` + TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"` Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` Comment string `json:"comment"` Reset int `json:"reset" gorm:"default:0"` diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 8c88a0922..827b5bd81 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) g.GET("/list/paged", a.listPaged) g.GET("/get/:email", a.get) + g.GET("/get/tgId/:tgId", a.getByTgId) g.GET("/traffic/:email", a.getTrafficByEmail) g.GET("/subLinks/:subId", a.getSubLinks) g.GET("/links/:email", a.getClientLinks) @@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) { jsonObj(c, resp, nil) } +func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) { + inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id) + if err != nil { + return nil, err + } + externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id) + if err != nil { + return nil, err + } + flow, err := a.clientService.EffectiveFlow(nil, rec.Id) + if err != nil { + return nil, err + } + rec.Flow = flow + var usedTraffic int64 + if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil { + usedTraffic = t.Up + t.Down + } + return gin.H{ + "client": rec, + "inboundIds": inboundIds, + "externalLinks": externalLinks, + "usedTraffic": usedTraffic, + }, nil +} + func (a *ClientController) get(c *gin.Context) { email := c.Param("email") rec, err := a.clientService.GetRecordByEmail(nil, email) @@ -112,30 +139,36 @@ func (a *ClientController) get(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } - inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id) + payload, err := a.buildClientPayload(rec) if err != nil { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } - externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id) + jsonObj(c, payload, nil) +} + +func (a *ClientController) getByTgId(c *gin.Context) { + tgIdStr := c.Param("tgId") + tgId, err := strconv.ParseInt(tgIdStr, 10, 64) if err != nil { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } - flow, err := a.clientService.EffectiveFlow(nil, rec.Id) + records, err := a.clientService.GetRecordsByTgID(tgId) if err != nil { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } - rec.Flow = flow - // Consumed bytes (up+down, including cross-node global overlay) so API - // consumers can pair usage with the client's totalGB quota (#4973). - // Best-effort: a traffic lookup failure must not break the client fetch. - var usedTraffic int64 - if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil { - usedTraffic = t.Up + t.Down + results := make([]gin.H, 0, len(records)) + for _, rec := range records { + payload, err := a.buildClientPayload(rec) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + results = append(results, payload) } - jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil) + jsonObj(c, results, nil) } func (a *ClientController) create(c *gin.Context) { diff --git a/internal/web/service/client_lookup.go b/internal/web/service/client_lookup.go index 2e7a5c111..dbd9f6d10 100644 --- a/internal/web/service/client_lookup.go +++ b/internal/web/service/client_lookup.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "errors" "strings" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -103,6 +104,15 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, return ids, nil } +func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) { + if tgId <= 0 { + return nil, errors.New("tg_id must be a positive integer") + } + var rows []*model.ClientRecord + err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error + return rows, err +} + func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) { row := &model.ClientRecord{} if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil { diff --git a/internal/web/service/client_lookup_test.go b/internal/web/service/client_lookup_test.go new file mode 100644 index 000000000..6230776a0 --- /dev/null +++ b/internal/web/service/client_lookup_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func TestGetRecordsByTgID(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + db := database.GetDB() + + records := []model.ClientRecord{ + {Email: "alice@x", TgID: 100, SubID: "sa"}, + {Email: "bob@x", TgID: 100, SubID: "sb"}, + {Email: "carol@x", TgID: 200, SubID: "sc"}, + {Email: "dave@x", TgID: 0, SubID: "sd"}, + } + for _, r := range records { + if err := db.Create(&r).Error; err != nil { + t.Fatalf("create record %q: %v", r.Email, err) + } + } + + t.Run("multiple clients share tgId", func(t *testing.T) { + got, err := svc.GetRecordsByTgID(100) + if err != nil { + t.Fatalf("GetRecordsByTgID(100): %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 records, got %d", len(got)) + } + emails := make(map[string]bool) + for _, r := range got { + emails[r.Email] = true + } + if !emails["alice@x"] || !emails["bob@x"] { + t.Fatalf("expected alice@x and bob@x, got %v", got) + } + }) + + t.Run("single client by tgId", func(t *testing.T) { + got, err := svc.GetRecordsByTgID(200) + if err != nil { + t.Fatalf("GetRecordsByTgID(200): %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 record, got %d", len(got)) + } + if got[0].Email != "carol@x" { + t.Fatalf("expected carol@x, got %s", got[0].Email) + } + }) + + t.Run("tgId zero rejected as sentinel", func(t *testing.T) { + _, err := svc.GetRecordsByTgID(0) + if err == nil { + t.Fatal("expected error for tgId=0") + } + }) + + t.Run("negative tgId rejected", func(t *testing.T) { + _, err := svc.GetRecordsByTgID(-5) + if err == nil { + t.Fatal("expected error for tgId=-5") + } + }) + + t.Run("nonexistent tgId returns empty", func(t *testing.T) { + got, err := svc.GetRecordsByTgID(999) + if err != nil { + t.Fatalf("GetRecordsByTgID(999): %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 records, got %d", len(got)) + } + }) +}