Browse Source

feat(sub): expose last subscription fetch time (#6217)

* feat(sub): expose last subscription fetch time

Record successful GET subscription fetches per client and surface the timestamp in the client API and UI.

* fix(frontend): include last subscription fetch in client traffic

* Update sub_fetch_test.go

---------

Co-authored-by: Hermes Agent <hermes-agent@localhost>
Maxim Myalin 5 hours ago
parent
commit
2217213e9f

+ 8 - 0
frontend/public/openapi.json

@@ -1324,6 +1324,11 @@
             "format": "int64",
             "type": "integer"
           },
+          "lastSubFetch": {
+            "example": 1735680000000,
+            "format": "int64",
+            "type": "integer"
+          },
           "reset": {
             "example": 0,
             "type": "integer"
@@ -1355,6 +1360,7 @@
           "id",
           "inboundId",
           "lastOnline",
+          "lastSubFetch",
           "reset",
           "subId",
           "total",
@@ -3139,6 +3145,7 @@
                           "id": 14825,
                           "inboundId": 1,
                           "lastOnline": 1735680000000,
+                          "lastSubFetch": 1735680000000,
                           "reset": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
@@ -7786,6 +7793,7 @@
                     "id": 14825,
                     "inboundId": 1,
                     "lastOnline": 1735680000000,
+                    "lastSubFetch": 1735680000000,
                     "reset": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,

+ 2 - 0
frontend/src/generated/examples.ts

@@ -305,6 +305,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "id": 14825,
     "inboundId": 1,
     "lastOnline": 1735680000000,
+    "lastSubFetch": 1735680000000,
     "reset": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
@@ -422,6 +423,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "id": 14825,
         "inboundId": 1,
         "lastOnline": 1735680000000,
+        "lastSubFetch": 1735680000000,
         "reset": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,

+ 6 - 0
frontend/src/generated/schemas.ts

@@ -1298,6 +1298,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "format": "int64",
         "type": "integer"
       },
+      "lastSubFetch": {
+        "example": 1735680000000,
+        "format": "int64",
+        "type": "integer"
+      },
       "reset": {
         "example": 0,
         "type": "integer"
@@ -1329,6 +1334,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "id",
       "inboundId",
       "lastOnline",
+      "lastSubFetch",
       "reset",
       "subId",
       "total",

+ 1 - 0
frontend/src/generated/types.ts

@@ -318,6 +318,7 @@ export interface ClientTraffic {
   id: number;
   inboundId: number;
   lastOnline: number;
+  lastSubFetch: number;
   reset: number;
   subId: string;
   total: number;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -340,6 +340,7 @@ export const ClientTrafficSchema = z.object({
   id: z.number().int(),
   inboundId: z.number().int(),
   lastOnline: z.number().int(),
+  lastSubFetch: z.number().int(),
   reset: z.number().int(),
   subId: z.string(),
   total: z.number().int(),

+ 4 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -218,7 +218,10 @@ export default function ClientInfoModal({
                     {client.enable && isOnline
                       ? <Tag color="green">{t('pages.clients.online')}</Tag>
                       : <Tag>{t('pages.clients.offline')}</Tag>}
-                    <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
+                    <span className="hint">
+                      {t('lastOnline')}: {dateLabel(traffic?.lastOnline)}
+                      {' · '}{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
+                    </span>
                   </td>
                 </tr>
                 <tr>

+ 2 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -852,7 +852,8 @@ export default function ClientsPage() {
       render: (_v, record) => {
         const bucket = clientBucket(record);
         const lastOnline = record.traffic?.lastOnline ?? 0;
-        const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}`;
+        const lastSubFetch = record.traffic?.lastSubFetch ?? 0;
+        const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}\n${t('lastSubFetch')}: ${lastSubFetch > 0 ? IntlUtil.formatDate(lastSubFetch, datepicker) : '-'}`;
         if (bucket === 'depleted') return (
           <Tooltip title={lastOnlineTitle}>
             <Tag color="red">{t('depleted')}</Tag>

+ 1 - 0
frontend/src/schemas/client.ts

@@ -10,6 +10,7 @@ export const ClientTrafficSchema = z.object({
   expiryTime: z.number().optional(),
   enable: z.boolean().optional(),
   lastOnline: z.number().optional(),
+  lastSubFetch: z.number().optional(),
 });
 
 export const ClientRecordSchema = z.object({

+ 11 - 0
internal/database/db.go

@@ -86,7 +86,18 @@ func allModels() []any {
 	}
 }
 
+func migrateClientTrafficLastSubFetchColumn() error {
+	migrator := db.Migrator()
+	if !migrator.HasTable(&xray.ClientTraffic{}) || migrator.HasColumn(&xray.ClientTraffic{}, "last_sub_fetch") {
+		return nil
+	}
+	return migrator.AddColumn(&xray.ClientTraffic{}, "LastSubFetch")
+}
+
 func initModels() error {
+	if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
+		return err
+	}
 	models := allModels()
 	for _, mdl := range models {
 		if IsPostgres() && postgresModelSettled(mdl) {

+ 16 - 0
internal/sub/controller.go

@@ -385,10 +385,12 @@ func (a *SUBController) subs(c *gin.Context) {
 		return
 	}
 	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
+		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "clash")
 		return
 	}
 	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
+		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "json")
 		return
 	}
@@ -425,6 +427,16 @@ func (a *SUBController) subs(c *gin.Context) {
 		} else {
 			c.String(200, result.String())
 		}
+		a.recordSubscriptionFetch(c)
+	}
+}
+
+func (a *SUBController) recordSubscriptionFetch(c *gin.Context) {
+	if c.Request == nil || c.Request.Method != http.MethodGet || c.Writer.Status() != http.StatusOK {
+		return
+	}
+	if err := a.subService.RecordSubscriptionFetch(c.Param("subid")); err != nil {
+		logger.Warning("Failed to record subscription fetch:", err)
 	}
 }
 
@@ -650,6 +662,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
 		if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
 			writeSubError(c, nil)
 		}
+		a.recordSubscriptionFetch(c)
 		return
 	}
 	if a.maybeServeSubPage(c) {
@@ -662,6 +675,7 @@ func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, conten
 	if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
 		writeSubError(c, nil)
 	}
+	a.recordSubscriptionFetch(c)
 }
 
 func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
@@ -693,6 +707,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
 		if !a.serveClashBody(c, true) {
 			writeSubError(c, nil)
 		}
+		a.recordSubscriptionFetch(c)
 		return
 	}
 	if a.maybeServeSubPage(c) {
@@ -701,6 +716,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
 	if !a.serveClashBody(c, false) {
 		writeSubError(c, nil)
 	}
+	a.recordSubscriptionFetch(c)
 }
 
 func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {

+ 10 - 0
internal/sub/service.go

@@ -273,6 +273,16 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 	return out
 }
 
+// RecordSubscriptionFetch records a successful subscription response for all clients sharing subId.
+func (s *SubService) RecordSubscriptionFetch(subId string) error {
+	if strings.TrimSpace(subId) == "" {
+		return nil
+	}
+	return database.GetDB().Model(&xray.ClientTraffic{}).
+		Where("email IN (SELECT email FROM clients WHERE sub_id = ?)", subId).
+		Update("last_sub_fetch", time.Now().UnixMilli()).Error
+}
+
 // GetSubs retrieves subscription links for a given subscription ID and host.
 func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	return s.ForRequest(host).getSubs(subId)

+ 99 - 0
internal/sub/sub_fetch_test.go

@@ -0,0 +1,99 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRecordSubscriptionFetch(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	clients := []model.ClientRecord{
+		{Email: "[email protected]", SubID: "sub-alpha", Enable: true},
+		{Email: "[email protected]", SubID: "sub-bravo", Enable: true},
+	}
+	for i := range clients {
+		if err := db.Create(&clients[i]).Error; err != nil {
+			t.Fatalf("create client %s: %v", clients[i].Email, err)
+		}
+		if err := db.Create(&xray.ClientTraffic{Email: clients[i].Email}).Error; err != nil {
+			t.Fatalf("create traffic %s: %v", clients[i].Email, err)
+		}
+	}
+
+	before := time.Now().UnixMilli()
+	if err := (&SubService{}).RecordSubscriptionFetch("sub-alpha"); err != nil {
+		t.Fatalf("RecordSubscriptionFetch: %v", err)
+	}
+
+	var alpha, bravo xray.ClientTraffic
+	if err := db.Where("email = ?", "[email protected]").First(&alpha).Error; err != nil {
+		t.Fatalf("load alpha traffic: %v", err)
+	}
+	if err := db.Where("email = ?", "[email protected]").First(&bravo).Error; err != nil {
+		t.Fatalf("load bravo traffic: %v", err)
+	}
+	if alpha.LastSubFetch < before {
+		t.Fatalf("alpha lastSubFetch = %d, want >= %d", alpha.LastSubFetch, before)
+	}
+	if bravo.LastSubFetch != 0 {
+		t.Fatalf("bravo lastSubFetch = %d, want 0", bravo.LastSubFetch)
+	}
+
+	if err := (&SubService{}).RecordSubscriptionFetch("unknown"); err != nil {
+		t.Fatalf("unknown subId: %v", err)
+	}
+	if err := (&SubService{}).RecordSubscriptionFetch(""); err != nil {
+		t.Fatalf("empty subId: %v", err)
+	}
+}
+
+func TestRecordSubscriptionFetchStatusGate(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	client := &model.ClientRecord{Email: "[email protected]", SubID: "sub-alpha", Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: client.Email}).Error; err != nil {
+		t.Fatalf("create traffic: %v", err)
+	}
+
+	controller := &SUBController{subService: &SubService{}}
+	notFoundRecorder := httptest.NewRecorder()
+	notFound, _ := gin.CreateTestContext(notFoundRecorder)
+	notFound.Request = httptest.NewRequest(http.MethodGet, "/sub/sub-alpha", nil)
+	notFound.Params = gin.Params{{Key: "subid", Value: "sub-alpha"}}
+	notFound.Status(http.StatusNotFound)
+	controller.recordSubscriptionFetch(notFound)
+
+	var traffic xray.ClientTraffic
+	if err := db.Where("email = ?", client.Email).First(&traffic).Error; err != nil {
+		t.Fatalf("load traffic after 404: %v", err)
+	}
+	if traffic.LastSubFetch != 0 {
+		t.Fatalf("404 updated lastSubFetch to %d", traffic.LastSubFetch)
+	}
+
+	okRecorder := httptest.NewRecorder()
+	ok, _ := gin.CreateTestContext(okRecorder)
+	ok.Request = httptest.NewRequest(http.MethodGet, "/sub/sub-alpha", nil)
+	ok.Params = gin.Params{{Key: "subid", Value: "sub-alpha"}}
+	ok.Status(http.StatusOK)
+	controller.recordSubscriptionFetch(ok)
+	if err := db.Where("email = ?", client.Email).First(&traffic).Error; err != nil {
+		t.Fatalf("load traffic after 200: %v", err)
+	}
+	if traffic.LastSubFetch == 0 {
+		t.Fatal("200 did not update lastSubFetch")
+	}
+}

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -66,6 +66,7 @@
   "comment": "تعليق",
   "success": "تم بنجاح",
   "lastOnline": "آخر متصل",
+  "lastSubFetch": "آخر جلب للاشتراك",
   "getVersion": "جيب النسخة",
   "install": "تثبيت",
   "clients": "عملاء",

+ 1 - 0
internal/web/translation/en-US.json

@@ -66,6 +66,7 @@
   "comment": "Comment",
   "success": "Success",
   "lastOnline": "Last Online",
+  "lastSubFetch": "Last Subscription Fetch",
   "getVersion": "Get Version",
   "install": "Install",
   "clients": "Clients",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -66,6 +66,7 @@
   "comment": "Comentario",
   "success": "Éxito",
   "lastOnline": "Última conexión",
+  "lastSubFetch": "Última descarga de suscripción",
   "getVersion": "Obtener versión",
   "install": "Instalar",
   "clients": "Clientes",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -66,6 +66,7 @@
   "comment": "توضیحات",
   "success": "موفق",
   "lastOnline": "آخرین فعالیت",
+  "lastSubFetch": "آخرین دریافت اشتراک",
   "getVersion": "دریافت نسخه",
   "install": "نصب",
   "clients": "کاربران",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -66,6 +66,7 @@
   "comment": "Komentar",
   "success": "Berhasil",
   "lastOnline": "Terakhir online",
+  "lastSubFetch": "Pengambilan langganan terakhir",
   "getVersion": "Dapatkan Versi",
   "install": "Instal",
   "clients": "Klien",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -66,6 +66,7 @@
   "comment": "コメント",
   "success": "成功",
   "lastOnline": "最終オンライン",
+  "lastSubFetch": "最終サブスクリプション取得",
   "getVersion": "バージョン取得",
   "install": "インストール",
   "clients": "クライアント",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -66,6 +66,7 @@
   "comment": "Comentário",
   "success": "Com Sucesso",
   "lastOnline": "Última vez online",
+  "lastSubFetch": "Última busca da assinatura",
   "getVersion": "Obter Versão",
   "install": "Instalar",
   "clients": "Clientes",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -66,6 +66,7 @@
   "comment": "Комментарий",
   "success": "Успешно",
   "lastOnline": "Был(а) в сети",
+  "lastSubFetch": "Последнее получение подписки",
   "getVersion": "Узнать версию",
   "install": "Установка",
   "clients": "Клиенты",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -66,6 +66,7 @@
   "comment": "Yorum",
   "success": "Başarılı",
   "lastOnline": "Son Çevrimiçi",
+  "lastSubFetch": "Son abonelik çekme",
   "getVersion": "Sürümü Al",
   "install": "Yükle",
   "clients": "Kullanıcılar",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -66,6 +66,7 @@
   "comment": "Коментар",
   "success": "Успішно",
   "lastOnline": "Був(ла) онлайн",
+  "lastSubFetch": "Останнє отримання підписки",
   "getVersion": "Отримати версію",
   "install": "Встановити",
   "clients": "Клієнти",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -66,6 +66,7 @@
   "comment": "Bình luận",
   "success": "Thành công",
   "lastOnline": "Lần online gần nhất",
+  "lastSubFetch": "Lần tải gói đăng ký gần nhất",
   "getVersion": "Lấy phiên bản",
   "install": "Cài đặt",
   "clients": "Các khách hàng",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -66,6 +66,7 @@
   "comment": "评论",
   "success": "成功",
   "lastOnline": "上次在线",
+  "lastSubFetch": "上次获取订阅",
   "getVersion": "获取版本",
   "install": "安装",
   "clients": "客户端",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -66,6 +66,7 @@
   "comment": "評論",
   "success": "成功",
   "lastOnline": "上次上線",
+  "lastSubFetch": "上次取得訂閱",
   "getVersion": "獲取版本",
   "install": "安裝",
   "clients": "客戶端",

+ 13 - 12
internal/xray/client_traffic.go

@@ -3,16 +3,17 @@ package xray
 // ClientTraffic represents traffic statistics and limits for a specific client.
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
-	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
-	InboundId  int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
-	Enable     bool   `json:"enable" form:"enable" example:"true"`
-	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
-	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
-	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
-	Up         int64  `json:"up" form:"up" example:"1048576"`
-	Down       int64  `json:"down" form:"down" example:"2097152"`
-	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
-	Total      int64  `json:"total" form:"total" example:"10737418240"`
-	Reset      int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
-	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
+	Id           int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
+	InboundId    int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
+	Enable       bool   `json:"enable" form:"enable" example:"true"`
+	Email        string `json:"email" form:"email" gorm:"unique" example:"user1"`
+	UUID         string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
+	SubId        string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
+	Up           int64  `json:"up" form:"up" example:"1048576"`
+	Down         int64  `json:"down" form:"down" example:"2097152"`
+	ExpiryTime   int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
+	Total        int64  `json:"total" form:"total" example:"10737418240"`
+	Reset        int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
+	LastOnline   int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
+	LastSubFetch int64  `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"`
 }