From 2217213e9f417d1ef28507b17f0819c5ee9fb46a Mon Sep 17 00:00:00 2001 From: Maxim Myalin Date: Sat, 15 Aug 2026 16:22:11 +0300 Subject: [PATCH] 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 --- frontend/public/openapi.json | 8 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 6 ++ frontend/src/generated/types.ts | 1 + frontend/src/generated/zod.ts | 1 + .../src/pages/clients/ClientInfoModal.tsx | 5 +- frontend/src/pages/clients/ClientsPage.tsx | 3 +- frontend/src/schemas/client.ts | 1 + internal/database/db.go | 11 +++ internal/sub/controller.go | 16 +++ internal/sub/service.go | 10 ++ internal/sub/sub_fetch_test.go | 99 +++++++++++++++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + internal/xray/client_traffic.go | 25 ++--- 26 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 internal/sub/sub_fetch_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 1dea05543..c6f8c94f0 100644 --- a/frontend/public/openapi.json +++ b/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, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 0a7d34cf3..02f8e03fb 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -305,6 +305,7 @@ export const EXAMPLES: Record = { "id": 14825, "inboundId": 1, "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, "reset": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, @@ -422,6 +423,7 @@ export const EXAMPLES: Record = { "id": 14825, "inboundId": 1, "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, "reset": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 651eafbae..455bd2459 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1298,6 +1298,11 @@ export const SCHEMAS: Record = { "format": "int64", "type": "integer" }, + "lastSubFetch": { + "example": 1735680000000, + "format": "int64", + "type": "integer" + }, "reset": { "example": 0, "type": "integer" @@ -1329,6 +1334,7 @@ export const SCHEMAS: Record = { "id", "inboundId", "lastOnline", + "lastSubFetch", "reset", "subId", "total", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index d5545d127..189624d15 100644 --- a/frontend/src/generated/types.ts +++ b/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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 5ef58fb2c..c2a10afa8 100644 --- a/frontend/src/generated/zod.ts +++ b/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(), diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index d09c9990c..d7dac19f9 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -218,7 +218,10 @@ export default function ClientInfoModal({ {client.enable && isOnline ? {t('pages.clients.online')} : {t('pages.clients.offline')}} - {t('lastOnline')}: {dateLabel(traffic?.lastOnline)} + + {t('lastOnline')}: {dateLabel(traffic?.lastOnline)} + {' · '}{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)} + diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 7035c9e2c..a13fa0a75 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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 ( {t('depleted')} diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index b2e34fec4..225a33a77 100644 --- a/frontend/src/schemas/client.ts +++ b/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({ diff --git a/internal/database/db.go b/internal/database/db.go index 3f06a8ba8..a5236aed7 100644 --- a/internal/database/db.go +++ b/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) { diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 65931d107..e6a1386ce 100644 --- a/internal/sub/controller.go +++ b/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 { diff --git a/internal/sub/service.go b/internal/sub/service.go index b6d2f5ba3..cc83ef8e1 100644 --- a/internal/sub/service.go +++ b/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) diff --git a/internal/sub/sub_fetch_test.go b/internal/sub/sub_fetch_test.go new file mode 100644 index 000000000..621c007f4 --- /dev/null +++ b/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: "alpha@example.com", SubID: "sub-alpha", Enable: true}, + {Email: "bravo@example.com", 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 = ?", "alpha@example.com").First(&alpha).Error; err != nil { + t.Fatalf("load alpha traffic: %v", err) + } + if err := db.Where("email = ?", "bravo@example.com").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: "alpha@example.com", 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") + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index d827d3bbc..5843ff974 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -66,6 +66,7 @@ "comment": "تعليق", "success": "تم بنجاح", "lastOnline": "آخر متصل", + "lastSubFetch": "آخر جلب للاشتراك", "getVersion": "جيب النسخة", "install": "تثبيت", "clients": "عملاء", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index e1e03db68..30cc60a6a 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 388d28caf..a5df087fd 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index d337883f3..553c51283 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -66,6 +66,7 @@ "comment": "توضیحات", "success": "موفق", "lastOnline": "آخرین فعالیت", + "lastSubFetch": "آخرین دریافت اشتراک", "getVersion": "دریافت نسخه", "install": "نصب", "clients": "کاربران", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index c82dab734..5d440ce8c 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 3e34e6a9f..8f6324c6d 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -66,6 +66,7 @@ "comment": "コメント", "success": "成功", "lastOnline": "最終オンライン", + "lastSubFetch": "最終サブスクリプション取得", "getVersion": "バージョン取得", "install": "インストール", "clients": "クライアント", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 56944a760..179312834 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 6369f60fe..f88b2aa77 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -66,6 +66,7 @@ "comment": "Комментарий", "success": "Успешно", "lastOnline": "Был(а) в сети", + "lastSubFetch": "Последнее получение подписки", "getVersion": "Узнать версию", "install": "Установка", "clients": "Клиенты", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index c23405b7e..a4c03ae01 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index ea4246863..a511b669e 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -66,6 +66,7 @@ "comment": "Коментар", "success": "Успішно", "lastOnline": "Був(ла) онлайн", + "lastSubFetch": "Останнє отримання підписки", "getVersion": "Отримати версію", "install": "Встановити", "clients": "Клієнти", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index c028ec577..88afded80 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 86fdca535..5787bb037 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -66,6 +66,7 @@ "comment": "评论", "success": "成功", "lastOnline": "上次在线", + "lastSubFetch": "上次获取订阅", "getVersion": "获取版本", "install": "安装", "clients": "客户端", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index b08ec771d..99a007626 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -66,6 +66,7 @@ "comment": "評論", "success": "成功", "lastOnline": "上次上線", + "lastSubFetch": "上次取得訂閱", "getVersion": "獲取版本", "install": "安裝", "clients": "客戶端", diff --git a/internal/xray/client_traffic.go b/internal/xray/client_traffic.go index 147878df7..35016ec08 100644 --- a/internal/xray/client_traffic.go +++ b/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"` }