feat: implement shared subscription traffic quota across multiple inbounds

- Added SubTotal synchronization across all clients sharing a SubID
- Implemented real-time traffic aggregation for subscription groups
- Updated UI to display shared remaining quota in info modals
- Added full i18n support for 13 languages
This commit is contained in:
SadeghKalami 2026-05-04 22:30:48 +03:30
parent 15ebf3df10
commit 32c7ceec55
22 changed files with 245 additions and 31 deletions

View file

@ -143,6 +143,7 @@ type Client struct {
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
SubTotalGB int64 `json:"subTotalGB" form:"subTotalGB"` // Shared total traffic limit in GB
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp

View file

@ -2394,6 +2394,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
reset = 0,
created_at = undefined,
updated_at = undefined,
subTotalGB = 0,
) {
super();
this.email = email;
@ -2407,6 +2408,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
this.reset = reset;
this.created_at = created_at;
this.updated_at = updated_at;
this.subTotalGB = subTotalGB;
}
static commonArgsFromJson(json = {}) {
@ -2422,6 +2424,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
json.reset,
json.created_at,
json.updated_at,
json.subTotalGB,
];
}
@ -2438,6 +2441,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
reset: this.reset,
created_at: this.created_at,
updated_at: this.updated_at,
subTotalGB: this.subTotalGB,
};
}
@ -2466,6 +2470,14 @@ Inbound.ClientBase = class extends XrayCommonClass {
set _totalGB(gb) {
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
}
get _subTotalGB() {
return NumberFormatter.toFixed(this.subTotalGB / SizeFormatter.ONE_GB, 2);
}
set _subTotalGB(gb) {
this.subTotalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
}
};
Inbound.VmessSettings = class extends Inbound.Settings {
@ -2511,9 +2523,9 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
security = USERS_SECURITY.AUTO,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB);
this.id = id;
this.security = security;
}
@ -2616,9 +2628,9 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
flow = '',
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB);
this.id = id;
this.flow = flow;
}
@ -2714,9 +2726,9 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
Inbound.TrojanSettings.Trojan = class extends Inbound.ClientBase {
constructor(
password = RandomUtil.randomSeq(10),
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB);
this.password = password;
}
@ -2816,9 +2828,9 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
constructor(
method = '',
password = RandomUtil.randomShadowsocksPassword(),
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB);
this.method = method;
this.password = password;
}
@ -2866,9 +2878,9 @@ Inbound.HysteriaSettings = class extends Inbound.Settings {
Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
constructor(
auth = RandomUtil.randomSeq(10),
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, subTotalGB);
this.auth = auth;
}

View file

@ -53,6 +53,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/lastOnline", a.lastOnline)
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
g.POST("/subTraffic/:subId", a.getSubTraffic)
}
type CopyInboundClientsRequest struct {
@ -109,6 +110,16 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) {
jsonObj(c, clientTraffics, nil)
}
func (a *InboundController) getSubTraffic(c *gin.Context) {
subId := c.Param("subId")
up, down, err := a.inboundService.GetSubTraffic(subId)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, map[string]int64{"up": up, "down": down}, nil)
}
// addInbound creates a new inbound configuration.
func (a *InboundController) addInbound(c *gin.Context) {
inbound := &model.Inbound{}

View file

@ -91,6 +91,10 @@
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(getRemStats(record, client.email)) ]]</td>
</tr>
<tr v-if="client.subTotalGB > 0">
<td>{{ i18n "pages.inbounds.subTotalFlow" }}</td>
<td>[[ SizeFormatter.sizeFormat(client.subTotalGB) ]]</td>
</tr>
</table>
</template>
<table>
@ -110,7 +114,11 @@
</td>
<td class="tr-table-lt">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
<span v-else class="tr-infinity-ch">&infin;</span>
<template v-if="client.subTotalGB > 0">
<br v-if="client.totalGB > 0" />
<span :style="{ fontSize: '10px', color: '#1890ff' }">[[ client._subTotalGB + "GB (Sub)" ]]</span>
</template>
<span v-if="client.totalGB <= 0 && client.subTotalGB <= 0" class="tr-infinity-ch">&infin;</span>
</td>
</tr>
</table>

View file

@ -141,6 +141,18 @@
</template>
<a-input-number v-model.number="client._totalGB" :min="0"></a-input-number>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
</template>
{{ i18n "pages.inbounds.subTotalFlow" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="client._subTotalGB" :min="0"></a-input-number>
</a-form-item>
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">
[[ SizeFormatter.sizeFormat(clientStats.up) ]] / [[

View file

@ -91,6 +91,18 @@
</template>
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0"></a-input-number>
</a-form-item>
<a-form-item v-if="app.subSettings?.enable">
<template slot="label">
<a-tooltip>
<template slot="title">
0 <span>{{ i18n "pages.inbounds.meansNoLimit" }}</span>
</template>
{{ i18n "pages.inbounds.subTotalFlow" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="clientsBulkModal.subTotalGB" :min="0"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
<a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch>
</a-form-item>
@ -140,6 +152,7 @@
inbound: new Inbound(),
quantity: 1,
totalGB: 0,
subTotalGB: 0,
limitIp: 0,
expiryTime: '',
emailMethod: 0,
@ -175,6 +188,7 @@
newClient.security = clientsBulkModal.security;
newClient.limitIp = clientsBulkModal.limitIp;
newClient._totalGB = clientsBulkModal.totalGB;
newClient._subTotalGB = clientsBulkModal.subTotalGB;
newClient._expiryTime = clientsBulkModal.expiryTime;
if (clientsBulkModal.inbound.canEnableTlsFlow()) {
newClient.flow = clientsBulkModal.flow;
@ -196,6 +210,7 @@
this.confirm = confirm;
this.quantity = 1;
this.totalGB = 0;
this.subTotalGB = 0;
this.expiryTime = 0;
this.emailMethod = 0;
this.limitIp = 0;

View file

@ -272,6 +272,22 @@
</td>
</tr>
</table>
<table v-if="infoModal.clientSettings.subId && infoModal.clientSettings.subTotalGB > 0" :style="{ display: 'inline-table', marginBlock: '10px', width: '100%', textAlign: 'center' }">
<tr>
<th>{{ i18n "remained" }} (Sub.)</th>
<th>{{ i18n "pages.inbounds.subTotalFlow" }}</th>
<th></th>
</tr>
<tr>
<td>
<a-tag color="blue"> [[ getSubRemStats() ]] </a-tag>
</td>
<td>
<a-tag color="blue"> [[ SizeFormatter.sizeFormat(infoModal.clientSettings.subTotalGB) ]] </a-tag>
</td>
<td></td>
</tr>
</table>
<table :style="{ display: 'inline-table', marginBlock: '10px', width: '100%', textAlign: 'center' }">
<tr>
<th>{{ i18n "remained" }}</th>
@ -639,6 +655,7 @@
subJsonLink: '',
clientIps: '',
clientIpsArray: [],
subTraffic: { up: 0, down: 0 },
show(dbInbound, index) {
this.index = index;
this.inbound = dbInbound.toInbound();
@ -674,6 +691,11 @@
if (this.clientSettings.subId) {
this.subLink = this.genSubLink(this.clientSettings.subId);
this.subJsonLink = app.subSettings.subJsonEnable ? this.genSubJsonLink(this.clientSettings.subId) : '';
HttpUtil.post(`/panel/api/inbounds/subTraffic/${this.clientSettings.subId}`).then(res => {
if (res.success) this.subTraffic = res.obj;
});
} else {
this.subTraffic = { up: 0, down: 0 };
}
}
this.visible = true;
@ -776,6 +798,13 @@
.down;
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
},
getSubRemStats() {
const stats = this.infoModal.subTraffic;
const settings = this.infoModal.clientSettings;
if (!stats || !settings || settings.subTotalGB <= 0) return "-";
let rem = settings.subTotalGB - (stats.up + stats.down);
return SizeFormatter.sizeFormat(rem < 0 ? 0 : rem);
},
refreshIPs() {
this.refreshing = true;
refreshIPs(this.infoModal.clientStats.email)

View file

@ -1552,6 +1552,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
Email string
}
// Check individual limits
err := tx.Table("inbounds").
Select("inbounds.tag, client_traffics.email").
Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
@ -1560,6 +1561,22 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if err != nil {
return false, 0, err
}
// Check shared quota (SubTotal)
var sharedResults []struct {
Tag string
Email string
}
err = tx.Table("inbounds").
Select("inbounds.tag, client_traffics.email").
Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
Where("client_traffics.sub_id IN (SELECT sub_id FROM client_traffics WHERE sub_id != '' AND sub_total > 0 AND enable = ? GROUP BY sub_id, sub_total HAVING SUM(up + down) >= sub_total)", true).
Where("client_traffics.enable = ?", true).
Scan(&sharedResults).Error
if err == nil {
results = append(results, sharedResults...)
}
s.xrayApi.Init(p.GetAPIPort())
for _, result := range results {
err1 := s.xrayApi.RemoveUser(result.Tag, result.Email)
@ -1569,22 +1586,27 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
logger.Debug("User is already disabled. Nothing to do more...")
} else {
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
logger.Debug("User is already disabled. Nothing to do more...")
} else {
logger.Debug("Error in disabling client by api:", err1)
needRestart = true
}
logger.Debug("Error in disabling client by api:", err1)
needRestart = true
}
}
}
s.xrayApi.Close()
}
// Update DB for individual limits
result := tx.Model(xray.ClientTraffic{}).
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
Update("enable", false)
err := result.Error
count := result.RowsAffected
// Update DB for shared quota
resultShared := tx.Exec("UPDATE client_traffics SET enable = ? WHERE sub_id IN (SELECT sub_id FROM client_traffics WHERE sub_id != '' AND sub_total > 0 AND enable = ? GROUP BY sub_id, sub_total HAVING SUM(up + down) >= sub_total) AND enable = ?", false, true, true)
if resultShared.Error == nil {
count += resultShared.RowsAffected
}
return needRestart, count, err
}
@ -1611,19 +1633,71 @@ func (s *InboundService) MigrationRemoveOrphanedTraffics() {
`)
}
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
clientTraffic := xray.ClientTraffic{}
clientTraffic.InboundId = inboundId
clientTraffic.Email = client.Email
clientTraffic.Total = client.TotalGB
clientTraffic.ExpiryTime = client.ExpiryTime
clientTraffic.Enable = client.Enable
clientTraffic.Up = 0
clientTraffic.Down = 0
clientTraffic.Reset = client.Reset
result := tx.Create(&clientTraffic)
err := result.Error
return err
func (s *InboundService) GetSubTraffic(subId string) (int64, int64, error) {
db := database.GetDB()
var result struct {
Up int64
Down int64
}
err := db.Model(xray.ClientTraffic{}).
Select("SUM(up) as up, SUM(down) as down").
Where("sub_id = ?", subId).
Scan(&result).Error
return result.Up, result.Down, err
}
func (s *InboundService) SyncSubTotal(tx *gorm.DB, subId string, subTotal int64) error {
if subId == "" {
return nil
}
// 1. Update client_traffics table
err := tx.Model(xray.ClientTraffic{}).
Where("sub_id = ?", subId).
Update("sub_total", subTotal).Error
if err != nil {
return err
}
// 2. Update inbounds table (JSON settings)
var inbounds []*model.Inbound
// Use a partial match to find relevant inbounds efficiently
err = tx.Where("settings LIKE ?", "%"+subId+"%").Find(&inbounds).Error
if err != nil {
return err
}
for _, inbound := range inbounds {
var settings map[string]any
err := json.Unmarshal([]byte(inbound.Settings), &settings)
if err != nil {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
modified := false
for _, c := range clients {
clientMap, ok := c.(map[string]any)
if !ok {
continue
}
if sid, ok := clientMap["subId"].(string); ok && sid == subId {
// Update subTotalGB in the JSON
clientMap["subTotalGB"] = subTotal
modified = true
}
}
if modified {
newSettings, _ := json.MarshalIndent(settings, "", " ")
err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("settings", string(newSettings)).Error
if err != nil {
logger.Warning("Failed to update inbound settings for sub_total sync:", err)
}
}
}
return nil
}
func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *model.Client) error {
@ -1635,8 +1709,33 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
"total": client.TotalGB,
"expiry_time": client.ExpiryTime,
"reset": client.Reset,
"sub_id": client.SubID,
"sub_total": client.SubTotalGB,
})
err := result.Error
if err == nil {
s.SyncSubTotal(tx, client.SubID, client.SubTotalGB)
}
return err
}
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
clientTraffic := xray.ClientTraffic{}
clientTraffic.InboundId = inboundId
clientTraffic.Email = client.Email
clientTraffic.Total = client.TotalGB
clientTraffic.ExpiryTime = client.ExpiryTime
clientTraffic.Enable = client.Enable
clientTraffic.Up = 0
clientTraffic.Down = 0
clientTraffic.Reset = client.Reset
clientTraffic.SubId = client.SubID
clientTraffic.SubTotal = client.SubTotalGB
result := tx.Create(&clientTraffic)
err := result.Error
if err == nil {
s.SyncSubTotal(tx, client.SubID, client.SubTotalGB)
}
return err
}

View file

@ -257,6 +257,8 @@
"monitorDesc" = "سيبها فاضية لو عايز تستمع على كل الـ IPs"
"meansNoLimit" = "= غير محدود. (الوحدة: جيجابايت)"
"totalFlow" = "إجمالي التدفق"
"subTotalFlow" = "إجمالي ترافيك الاشتراك"
"subTotalFlowDesc" = "حد الترافيك الإجمالي لكل العملاء اللي بيستخدموا نفس الـ SubID ده"
"leaveBlankToNeverExpire" = "سيبها فاضية عشان ماتنتهيش"
"noRecommendKeepDefault" = "ننصح باستخدام الافتراضي"
"certificatePath" = "مسار الملف"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Leave blank to listen on all IPs"
"meansNoLimit" = "= Unlimited. (unit: GB)"
"totalFlow" = "Total Flow"
"subTotalFlow" = "Sub. Total Flow"
"subTotalFlowDesc" = "Aggregated traffic limit for all clients sharing this SubID"
"leaveBlankToNeverExpire" = "Leave blank to never expire"
"noRecommendKeepDefault" = "It is recommended to keep the default"
"certificatePath" = "File Path"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Dejar en blanco por defecto"
"meansNoLimit" = " = illimitata. (unidad: GB)"
"totalFlow" = "Flujo Total"
"subTotalFlow" = "Tráfico total de suscripción"
"subTotalFlowDesc" = "Límite de tráfico agregado para todos los clientes que comparten este SubID"
"leaveBlankToNeverExpire" = "Dejar en Blanco para Nunca Expirar"
"noRecommendKeepDefault" = "No hay requisitos especiales para mantener la configuración predeterminada"
"certificatePath" = "Ruta Cert"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "به‌طور پیش‌فرض خالی‌بگذارید"
"meansNoLimit" = "0 = واحد: گیگابایت) نامحدود)"
"totalFlow" = "ترافیک کل"
"subTotalFlow" = "ترافیک کل اشتراک"
"subTotalFlowDesc" = "مجموع حجم مجاز برای تمام کاربران با این شناسه اشتراک"
"leaveBlankToNeverExpire" = "برای منقضی‌نشدن خالی‌بگذارید"
"noRecommendKeepDefault" = "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود"
"certificatePath" = "مسیر فایل"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Biarkan kosong untuk mendengarkan semua IP"
"meansNoLimit" = "= Unlimited. (unit: GB)"
"totalFlow" = "Total Aliran"
"subTotalFlow" = "Total Lalu Lintas Langganan"
"subTotalFlowDesc" = "Batas lalu lintas agregat untuk semua klien yang berbagi SubID ini"
"leaveBlankToNeverExpire" = "Biarkan kosong untuk tidak pernah kedaluwarsa"
"noRecommendKeepDefault" = "Disarankan untuk tetap menggunakan pengaturan default"
"certificatePath" = "Path Berkas"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "空白にするとすべてのIPを監視"
"meansNoLimit" = "= 無制限単位GB"
"totalFlow" = "総トラフィック"
"subTotalFlow" = "サブスクリプション総流量"
"subTotalFlowDesc" = "このSubIDを共有するすべてのクライアントの集計トラフィック制限"
"leaveBlankToNeverExpire" = "空白にすると期限なし"
"noRecommendKeepDefault" = "デフォルト値を保持することをお勧めします"
"certificatePath" = "ファイルパス"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Deixe em branco para ouvir todos os IPs"
"meansNoLimit" = "= Ilimitado. (unidade: GB)"
"totalFlow" = "Fluxo Total"
"subTotalFlow" = "Fluxo Total de Subscrição"
"subTotalFlowDesc" = "Limite de tráfego agregado para todos os clientes que compartilham este SubID"
"leaveBlankToNeverExpire" = "Deixe em branco para nunca expirar"
"noRecommendKeepDefault" = "Recomenda-se manter o padrão"
"certificatePath" = "Caminho"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Оставьте пустым для прослушивания всех IP-адресов"
"meansNoLimit" = "= Без ограничений (значение: ГБ)"
"totalFlow" = "Общий расход"
"subTotalFlow" = "Общий трафик подписки"
"subTotalFlowDesc" = "Общий лимит трафика для всех клиентов с этим SubID"
"leaveBlankToNeverExpire" = "Оставьте пустым, чтобы было бесконечным"
"noRecommendKeepDefault" = "Рекомендуется оставить настройки по умолчанию"
"certificatePath" = "Путь к сертификату"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Tüm IP'leri dinlemek için boş bırakın"
"meansNoLimit" = "= Sınırsız. (birim: GB)"
"totalFlow" = "Toplam Akış"
"subTotalFlow" = "Abonelik Toplam Trafiği"
"subTotalFlowDesc" = "Bu SubID'yi paylaşan tüm istemciler için toplam trafik sınırı"
"leaveBlankToNeverExpire" = "Hiçbir zaman sona ermemesi için boş bırakın"
"noRecommendKeepDefault" = "Varsayılanı korumanız önerilir"
"certificatePath" = "Dosya Yolu"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Залиште порожнім, щоб слухати всі IP-адреси"
"meansNoLimit" = "= Необмежено. (одиниця: ГБ)"
"totalFlow" = "Загальна витрата"
"subTotalFlow" = "Загальний трафік підписки"
"subTotalFlowDesc" = "Сумарне обмеження трафіку для всіх клієнтів із цим SubID"
"leaveBlankToNeverExpire" = "Залиште порожнім, щоб ніколи не закінчувався"
"noRecommendKeepDefault" = "Рекомендується зберегти значення за замовчуванням"
"certificatePath" = "Шлях до файлу"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "Mặc định để trống"
"meansNoLimit" = "= Không giới hạn (đơn vị: GB)"
"totalFlow" = "Tổng lưu lượng"
"subTotalFlow" = "Tổng lưu lượng gói đăng ký"
"subTotalFlowDesc" = "Giới hạn lưu lượng tổng hợp cho tất cả người dùng dùng chung SubID này"
"leaveBlankToNeverExpire" = "Để trống để không bao giờ hết hạn"
"noRecommendKeepDefault" = "Không yêu cầu đặc biệt để giữ nguyên cài đặt mặc định"
"certificatePath" = "Đường dẫn tập"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "留空表示监听所有 IP"
"meansNoLimit" = "= 无限制单位GB)"
"totalFlow" = "总流量"
"subTotalFlow" = "订阅总流量"
"subTotalFlowDesc" = "此订阅 ID 下所有客户端的累计流量限制"
"leaveBlankToNeverExpire" = "留空表示永不过期"
"noRecommendKeepDefault" = "建议保留默认值"
"certificatePath" = "文件路径"

View file

@ -257,6 +257,8 @@
"monitorDesc" = "留空表示監聽所有 IP"
"meansNoLimit" = "= 無限制單位GB)"
"totalFlow" = "總流量"
"subTotalFlow" = "訂閱總流量"
"subTotalFlowDesc" = "此 SubID 共享的所有客戶端的流量限制總額"
"leaveBlankToNeverExpire" = "留空表示永不過期"
"noRecommendKeepDefault" = "建議保留預設值"
"certificatePath" = "檔案路徑"

View file

@ -8,12 +8,13 @@ type ClientTraffic struct {
Enable bool `json:"enable" form:"enable"`
Email string `json:"email" form:"email" gorm:"unique"`
UUID string `json:"uuid" form:"uuid" gorm:"-"`
SubId string `json:"subId" form:"subId" gorm:"-"`
SubId string `json:"subId" form:"subId" gorm:"index"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
AllTime int64 `json:"allTime" form:"allTime"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
Total int64 `json:"total" form:"total"`
SubTotal int64 `json:"subTotal" form:"subTotal"`
Reset int `json:"reset" form:"reset" gorm:"default:0"`
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
}