mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 05:10:58 +00:00
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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user