Преглед изворни кода

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.
Kim Fom пре 6 часа
родитељ
комит
6af2995930

+ 41 - 0
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": {
     "/panel/api/clients/add": {
       "post": {
       "post": {
         "tags": [
         "tags": [

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

@@ -582,6 +582,16 @@ export const sections: readonly Section[] = [
         response:
         response:
           '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n  }\n}',
           '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\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": "[email protected]", ... },\n      "inboundIds": [3, 5],\n      "externalLinks": [],\n      "usedTraffic": 1048576\n    }\n  ]\n}',
+      },
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/add',
         path: '/panel/api/clients/add',

+ 14 - 0
internal/database/db.go

@@ -131,6 +131,9 @@ func initModels() error {
 	if err := migrateVmessRemovedSecurities(); err != nil {
 	if err := migrateVmessRemovedSecurities(); err != nil {
 		return err
 		return err
 	}
 	}
+	if err := migrateTgIDIndex(); err != nil {
+		return err
+	}
 	if IsPostgres() {
 	if IsPostgres() {
 		if err := resyncPostgresSequences(db, models); err != nil {
 		if err := resyncPostgresSequences(db, models); err != nil {
 			log.Printf("Error resyncing postgres sequences: %v", err)
 			log.Printf("Error resyncing postgres sequences: %v", err)
@@ -884,6 +887,17 @@ func migrateVmessRemovedSecurities() error {
 	return nil
 	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
 // normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
 // minimum (rows written by builds that defaulted the column to 0, or by nodes
 // 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.
 // predating the field) so they cannot sort ahead of explicitly ranked inbounds.

+ 1 - 1
internal/database/model/model.go

@@ -904,7 +904,7 @@ type ClientRecord struct {
 	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
 	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
 	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	Enable       bool   `json:"enable" gorm:"default:true"`
 	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"`
 	Group        string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
 	Comment      string `json:"comment"`
 	Comment      string `json:"comment"`
 	Reset        int    `json:"reset" gorm:"default:0"`
 	Reset        int    `json:"reset" gorm:"default:0"`

+ 44 - 11
internal/web/controller/client.go

@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.GET("/list", a.list)
 	g.GET("/list", a.list)
 	g.GET("/list/paged", a.listPaged)
 	g.GET("/list/paged", a.listPaged)
 	g.GET("/get/:email", a.get)
 	g.GET("/get/:email", a.get)
+	g.GET("/get/tgId/:tgId", a.getByTgId)
 	g.GET("/traffic/:email", a.getTrafficByEmail)
 	g.GET("/traffic/:email", a.getTrafficByEmail)
 	g.GET("/subLinks/:subId", a.getSubLinks)
 	g.GET("/subLinks/:subId", a.getSubLinks)
 	g.GET("/links/:email", a.getClientLinks)
 	g.GET("/links/:email", a.getClientLinks)
@@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) {
 	jsonObj(c, resp, nil)
 	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) {
 func (a *ClientController) get(c *gin.Context) {
 	email := c.Param("email")
 	email := c.Param("email")
 	rec, err := a.clientService.GetRecordByEmail(nil, 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)
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
-	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
+	payload, err := a.buildClientPayload(rec)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		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 {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
-	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
+	records, err := a.clientService.GetRecordsByTgID(tgId)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		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) {
 func (a *ClientController) create(c *gin.Context) {

+ 10 - 0
internal/web/service/client_lookup.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"strings"
 	"strings"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"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
 	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) {
 func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
 	row := &model.ClientRecord{}
 	row := &model.ClientRecord{}
 	if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
 	if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {

+ 80 - 0
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))
+		}
+	})
+}