refactor UI and clients logic

This commit is contained in:
Konstantin Pichugin 2026-01-12 00:12:14 +03:00
parent fa7759280b
commit 5ed25ee08e
19 changed files with 1810 additions and 728 deletions

View file

@ -135,9 +135,10 @@ type ClientEntity struct {
Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks)
Flow string `json:"flow" form:"flow"` // Flow control (XTLS)
LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
TotalGB float64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB (supports decimal values like 0.01 for MB)
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
Status string `json:"status" form:"status" gorm:"default:active"` // Client status: active, expired_traffic, expired_time
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
@ -148,11 +149,11 @@ type ClientEntity struct {
// Relations (not stored in DB, loaded via joins)
InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to
// Traffic statistics (loaded from client_traffics table, not stored in ClientEntity table)
Up int64 `json:"up,omitempty" form:"-" gorm:"-"` // Upload traffic in bytes
Down int64 `json:"down,omitempty" form:"-" gorm:"-"` // Download traffic in bytes
AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"-"` // All-time traffic usage
LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"-"` // Last online timestamp
// Traffic statistics (stored directly in ClientEntity table)
Up int64 `json:"up,omitempty" form:"-" gorm:"default:0"` // Upload traffic in bytes
Down int64 `json:"down,omitempty" form:"-" gorm:"default:0"` // Download traffic in bytes
AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"default:0"` // All-time traffic usage
LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"default:0"` // Last online timestamp
// HWID (Hardware ID) restrictions
HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client

View file

@ -62,6 +62,36 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin
} else if clientEntity != nil {
logger.Debugf("GetSubs: Found client by subId '%s': clientId=%d, email=%s, hwidEnabled=%v",
subId, clientEntity.Id, clientEntity.Email, clientEntity.HWIDEnabled)
// Check traffic limits and expiry time before returning subscription
// Traffic statistics are now stored directly in ClientEntity
now := time.Now().Unix() * 1000
totalUsed := clientEntity.Up + clientEntity.Down
trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024)
trafficExceeded := clientEntity.TotalGB > 0 && totalUsed >= trafficLimit
timeExpired := clientEntity.ExpiryTime > 0 && clientEntity.ExpiryTime <= now
// Check if client exceeded limits - set status but keep Enable = true to allow subscription
if trafficExceeded || timeExpired {
// Client exceeded limits - set status but keep Enable = true
// Subscription should still work to show traffic information to client
status := "expired_traffic"
if timeExpired {
status = "expired_time"
}
// Update status if not already set
if clientEntity.Status != status {
db.Model(&model.ClientEntity{}).Where("id = ?", clientEntity.Id).Update("status", status)
clientEntity.Status = status
logger.Warningf("GetSubs: Client %s (subId: %s) exceeded limits - set status to %s: trafficExceeded=%v, timeExpired=%v, totalUsed=%d, total=%d",
clientEntity.Email, subId, status, trafficExceeded, timeExpired, totalUsed, trafficLimit)
}
// Continue to generate subscription - client will be blocked in Xray config, not in subscription
}
// Note: We don't block subscription even if client has expired status
// Subscription provides traffic information, and client blocking is handled in Xray config
}
// Register HWID from headers if context is provided and client is found
@ -106,7 +136,16 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin
result = append(result, linkLine)
}
}
ct := s.getClientTraffics(inbound.ClientStats, clientEntity.Email)
// Create ClientTraffic from ClientEntity for statistics (traffic is stored in ClientEntity now)
trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024)
ct := xray.ClientTraffic{
Email: clientEntity.Email,
Up: clientEntity.Up,
Down: clientEntity.Down,
Total: trafficLimit,
ExpiryTime: clientEntity.ExpiryTime,
LastOnline: clientEntity.LastOnline,
}
clientTraffics = append(clientTraffics, ct)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
@ -122,6 +161,39 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin
}
for _, client := range clients {
if client.Enable && client.SubID == subId {
// Use ClientEntity for traffic (new architecture only)
var clientEntity model.ClientEntity
err = db.Where("LOWER(email) = ?", strings.ToLower(client.Email)).First(&clientEntity).Error
if err != nil {
// Client not found in ClientEntity - skip (old architecture clients should be migrated)
logger.Warningf("GetSubs: Client %s (subId: %s) not found in ClientEntity - skipping",
client.Email, subId)
continue
}
// Check traffic limits from ClientEntity
now := time.Now().Unix() * 1000
totalUsed := clientEntity.Up + clientEntity.Down
trafficLimit := int64(clientEntity.TotalGB * 1024 * 1024 * 1024)
trafficExceeded := clientEntity.TotalGB > 0 && totalUsed >= trafficLimit
timeExpired := clientEntity.ExpiryTime > 0 && clientEntity.ExpiryTime <= now
if trafficExceeded || timeExpired || !clientEntity.Enable {
logger.Warningf("GetSubs: Client %s (subId: %s) exceeded limits or disabled - skipping",
client.Email, subId)
continue
}
// Create ClientTraffic from ClientEntity for statistics
clientTraffic := xray.ClientTraffic{
Email: clientEntity.Email,
Up: clientEntity.Up,
Down: clientEntity.Down,
Total: trafficLimit,
ExpiryTime: clientEntity.ExpiryTime,
LastOnline: clientEntity.LastOnline,
}
link := s.getLink(inbound, client.Email)
// Split link by newline to handle multiple links (for multiple nodes)
linkLines := strings.Split(link, "\n")
@ -132,6 +204,9 @@ func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]strin
}
}
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
if ct.Email == "" {
ct = clientTraffic
}
clientTraffics = append(clientTraffics, ct)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline

View file

@ -38,6 +38,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/add", a.addClient)
g.POST("/update/:id", a.updateClient)
g.POST("/del/:id", a.deleteClient)
g.POST("/resetAllTraffics", a.resetAllClientTraffics)
g.POST("/resetTraffic/:id", a.resetClientTraffic)
g.POST("/delDepletedClients", a.delDepletedClients)
}
// getClients retrieves the list of all clients for the current user.
@ -163,6 +166,17 @@ func (a *ClientController) updateClient(c *gin.Context) {
user := session.GetLoginUser(c)
// Get existing client first to preserve fields not being updated
existing, err := a.clientService.GetClient(id)
if err != nil {
jsonMsg(c, "Client not found", err)
return
}
if existing.UserId != user.Id {
jsonMsg(c, "Client not found or access denied", nil)
return
}
// Extract inboundIds from JSON or form data
var inboundIdsFromJSON []int
var hasInboundIdsInJSON bool
@ -198,11 +212,121 @@ func (a *ClientController) updateClient(c *gin.Context) {
}
}
client := &model.ClientEntity{}
err = c.ShouldBind(client)
if err != nil {
jsonMsg(c, "Invalid client data", err)
return
// Use existing client as base and update only provided fields
client := existing
// Try to bind only provided fields - use ShouldBindJSON for JSON requests
if c.ContentType() == "application/json" {
var updateData map[string]interface{}
if err := c.ShouldBindJSON(&updateData); err == nil {
// Update only fields that are present in the request
if email, ok := updateData["email"].(string); ok && email != "" {
client.Email = email
}
if uuid, ok := updateData["uuid"].(string); ok && uuid != "" {
client.UUID = uuid
}
if security, ok := updateData["security"].(string); ok && security != "" {
client.Security = security
}
if password, ok := updateData["password"].(string); ok && password != "" {
client.Password = password
}
if flow, ok := updateData["flow"].(string); ok && flow != "" {
client.Flow = flow
}
if limitIP, ok := updateData["limitIp"].(float64); ok {
client.LimitIP = int(limitIP)
} else if limitIP, ok := updateData["limitIp"].(int); ok {
client.LimitIP = limitIP
}
if totalGB, ok := updateData["totalGB"].(float64); ok {
client.TotalGB = totalGB
} else if totalGB, ok := updateData["totalGB"].(int); ok {
client.TotalGB = float64(totalGB)
} else if totalGB, ok := updateData["totalGB"].(int64); ok {
client.TotalGB = float64(totalGB)
}
if expiryTime, ok := updateData["expiryTime"].(float64); ok {
client.ExpiryTime = int64(expiryTime)
} else if expiryTime, ok := updateData["expiryTime"].(int64); ok {
client.ExpiryTime = expiryTime
}
if enable, ok := updateData["enable"].(bool); ok {
client.Enable = enable
}
if tgID, ok := updateData["tgId"].(float64); ok {
client.TgID = int64(tgID)
} else if tgID, ok := updateData["tgId"].(int64); ok {
client.TgID = tgID
}
if subID, ok := updateData["subId"].(string); ok && subID != "" {
client.SubID = subID
}
if comment, ok := updateData["comment"].(string); ok && comment != "" {
client.Comment = comment
}
if reset, ok := updateData["reset"].(float64); ok {
client.Reset = int(reset)
} else if reset, ok := updateData["reset"].(int); ok {
client.Reset = reset
}
if hwidEnabled, ok := updateData["hwidEnabled"].(bool); ok {
client.HWIDEnabled = hwidEnabled
}
if maxHwid, ok := updateData["maxHwid"].(float64); ok {
client.MaxHWID = int(maxHwid)
} else if maxHwid, ok := updateData["maxHwid"].(int); ok {
client.MaxHWID = maxHwid
}
}
} else {
// For form data, use ShouldBind
updateClient := &model.ClientEntity{}
if err := c.ShouldBind(updateClient); err == nil {
// Update only non-empty fields
if updateClient.Email != "" {
client.Email = updateClient.Email
}
if updateClient.UUID != "" {
client.UUID = updateClient.UUID
}
if updateClient.Security != "" {
client.Security = updateClient.Security
}
if updateClient.Password != "" {
client.Password = updateClient.Password
}
if updateClient.Flow != "" {
client.Flow = updateClient.Flow
}
if updateClient.LimitIP > 0 {
client.LimitIP = updateClient.LimitIP
}
if updateClient.TotalGB > 0 {
client.TotalGB = updateClient.TotalGB
}
if updateClient.ExpiryTime != 0 {
client.ExpiryTime = updateClient.ExpiryTime
}
// Always update enable if it's in the request (even if false)
enableStr := c.PostForm("enable")
if enableStr != "" {
client.Enable = enableStr == "true" || enableStr == "1"
}
if updateClient.TgID > 0 {
client.TgID = updateClient.TgID
}
if updateClient.SubID != "" {
client.SubID = updateClient.SubID
}
if updateClient.Comment != "" {
client.Comment = updateClient.Comment
}
if updateClient.Reset > 0 {
client.Reset = updateClient.Reset
}
}
}
// Set inboundIds from JSON if available
@ -272,3 +396,72 @@ func (a *ClientController) deleteClient(c *gin.Context) {
}
}
}
// resetAllClientTraffics resets traffic counters for all clients of the current user.
func (a *ClientController) resetAllClientTraffics(c *gin.Context) {
user := session.GetLoginUser(c)
needRestart, err := a.clientService.ResetAllClientTraffics(user.Id)
if err != nil {
logger.Errorf("Failed to reset all client traffics: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after resetting all client traffics: %v", err)
}
}
}
// resetClientTraffic resets traffic counter for a specific client.
func (a *ClientController) resetClientTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid client ID", err)
return
}
user := session.GetLoginUser(c)
needRestart, err := a.clientService.ResetClientTraffic(user.Id, id)
if err != nil {
logger.Errorf("Failed to reset client traffic: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after client traffic reset: %v", err)
}
}
}
// delDepletedClients deletes clients that have exhausted their traffic limits or expired.
func (a *ClientController) delDepletedClients(c *gin.Context) {
user := session.GetLoginUser(c)
count, needRestart, err := a.clientService.DelDepletedClients(user.Id)
if err != nil {
logger.Errorf("Failed to delete depleted clients: %v", err)
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if count > 0 {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
if needRestart {
// In multi-node mode, this will send config to nodes immediately
// In single mode, this will restart local Xray
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after deleting depleted clients: %v", err)
}
}
} else {
jsonMsg(c, "No depleted clients found", nil)
}
}

View file

@ -8,6 +8,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/entity"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin"
)
@ -92,6 +93,16 @@ func getContext(h gin.H) gin.H {
a := gin.H{
"cur_ver": config.GetVersion(),
}
// Add multiNodeMode to context for all pages
settingService := service.SettingService{}
multiNodeMode, err := settingService.GetMultiNodeMode()
if err != nil {
// If error, default to false (single mode)
multiNodeMode = false
}
a["multiNodeMode"] = multiNodeMode
for key, value := range h {
a[key] = value
}

View file

@ -10,37 +10,76 @@
<transition name="list" appear>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>{{ i18n "pages.clients.title" }}</h2>
<div style="margin-bottom: 20px;">
<a-button type="primary" icon="plus" @click="openAddClient">{{ i18n "pages.clients.addClient" }}</a-button>
<a-button icon="sync" @click="manualRefresh" :loading="refreshing" style="margin-left: 10px;">{{ i18n "refresh" }}</a-button>
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme" style="margin-left: 10px;">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
<span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
<a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
@change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select>
</a-space>
</template>
<a-button icon="down"></a-button>
</a-popover>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="client => client.id"
:data-source="clients" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="clients-table"
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<a-button type="primary" icon="plus" @click="openAddClient">
<template v-if="!isMobile">{{ i18n "pages.clients.addClient" }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary" icon="menu">
<template v-if="!isMobile">{{ i18n "pages.inbounds.generalActions" }}</template>
</a-button>
<a-menu slot="overlay" @click="a => generalActions(a)" :theme="themeSwitcher.currentTheme">
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</a-space>
</template>
<template #extra>
<a-button-group>
<a-button icon="sync" @click="manualRefresh" :loading="refreshing"></a-button>
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
<span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
<a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
@change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select>
</a-space>
</template>
<a-button icon="down"></a-button>
</a-popover>
</a-button-group>
</template>
<a-space direction="vertical">
<div :style="isMobile ? {} : { display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }">
<a-switch v-model="enableFilter"
:style="isMobile ? { marginBottom: '.5rem', display: 'flex' } : { marginRight: '.5rem' }"
@change="toggleFilter">
<a-icon slot="checkedChildren" type="search"></a-icon>
<a-icon slot="unCheckedChildren" type="filter"></a-icon>
</a-switch>
<a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
:style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
<a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterClients" button-style="solid"
:size="isMobile ? 'small' : ''">
<a-radio-button value="">{{ i18n "none" }}</a-radio-button>
<a-radio-button value="deactive">{{ i18n "disabled" }}</a-radio-button>
<a-radio-button value="depleted">{{ i18n "depleted" }}</a-radio-button>
<a-radio-button value="expiring">{{ i18n "depletingSoon" }}</a-radio-button>
<a-radio-button value="online">{{ i18n "online" }}</a-radio-button>
</a-radio-group>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="client => client.id"
:data-source="searchedClients" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="clients-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, client">
<a-dropdown :trigger="['click']">
@ -56,6 +95,10 @@
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item key="delete" :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
{{ i18n "delete" }}
@ -86,8 +129,8 @@
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>[[ SizeFormatter.sizeFormat(client.up || 0) ]]</td>
<td>[[ SizeFormatter.sizeFormat(client.down || 0) ]]</td>
<td>[[ SizeFormatter.sizeFormat(client.up || 0) ]]</td>
<td>[[ SizeFormatter.sizeFormat(client.down || 0) ]]</td>
</tr>
<tr v-if="getClientTotal(client) > 0 && (client.up || 0) + (client.down || 0) < getClientTotal(client)">
<td>{{ i18n "remained" }}</td>
@ -125,6 +168,7 @@
</a-tag>
</template>
</a-table>
</a-space>
</a-card>
</a-col>
</a-row>
@ -225,6 +269,7 @@
spinning: false
},
clients: [],
searchedClients: [],
allInbounds: [],
availableNodes: [],
refreshing: false,
@ -232,6 +277,11 @@
lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
searchKey: '',
enableFilter: false,
filterBy: '',
expireDiff: 0,
trafficDiff: 0,
subSettings: {
enable: false,
subTitle: '',
@ -257,6 +307,16 @@
this.clients = msg.obj;
// Load inbounds for each client
await this.loadInboundsForClients();
// Apply current filter/search
if (this.enableFilter) {
this.filterClients();
} else {
this.searchClients(this.searchKey);
}
// Ensure searchedClients is initialized
if (this.searchedClients.length === 0 && this.clients.length > 0) {
this.searchedClients = this.clients.slice();
}
}
} catch (e) {
console.error("Failed to load clients:", e);
@ -296,6 +356,7 @@
},
getClientTotal(client) {
// Convert TotalGB to bytes (1 GB = 1024^3 bytes)
// TotalGB can now be a decimal value (e.g., 0.01 for MB)
if (client.totalGB && client.totalGB > 0) {
return client.totalGB * 1024 * 1024 * 1024;
}
@ -327,6 +388,8 @@
return;
}
with (msg.obj) {
this.expireDiff = expireDiff * 86400000;
this.trafficDiff = trafficDiff * 1073741824;
this.subSettings = {
enable: subEnable,
subTitle: subTitle,
@ -360,6 +423,9 @@
case 'edit':
this.editClient(client);
break;
case 'resetTraffic':
this.resetClientTraffic(client);
break;
case 'delete':
this.deleteClient(client.id);
break;
@ -655,6 +721,143 @@
await this.loadClients();
this.loadingStates.spinning = false;
}
},
searchClients(key) {
if (ObjectUtil.isEmpty(key)) {
this.searchedClients = this.clients.slice();
} else {
this.searchedClients.splice(0, this.searchedClients.length);
this.clients.forEach(client => {
if (ObjectUtil.deepSearch(client, key)) {
this.searchedClients.push(client);
}
});
}
},
filterClients() {
if (ObjectUtil.isEmpty(this.filterBy)) {
this.searchedClients = this.clients.slice();
} else {
this.searchedClients.splice(0, this.searchedClients.length);
const now = new Date().getTime();
this.clients.forEach(client => {
let shouldInclude = false;
switch (this.filterBy) {
case 'deactive':
shouldInclude = !client.enable;
break;
case 'depleted':
const exhausted = client.totalGB > 0 && (client.up || 0) + (client.down || 0) >= client.totalGB * 1024 * 1024 * 1024;
const expired = client.expiryTime > 0 && client.expiryTime <= now;
shouldInclude = expired || exhausted;
break;
case 'expiring':
const expiringSoon = (client.expiryTime > 0 && (client.expiryTime - now < this.expireDiff)) ||
(client.totalGB > 0 && (client.totalGB * 1024 * 1024 * 1024 - (client.up || 0) - (client.down || 0) < this.trafficDiff));
shouldInclude = expiringSoon && !this.isClientDepleted(client);
break;
case 'online':
shouldInclude = this.isClientOnline(client.email);
break;
}
if (shouldInclude) {
this.searchedClients.push(client);
}
});
}
},
toggleFilter() {
if (this.enableFilter) {
this.searchKey = '';
} else {
this.filterBy = '';
this.searchedClients = this.clients.slice();
}
},
isClientDepleted(client) {
const now = new Date().getTime();
const exhausted = client.totalGB > 0 && (client.up || 0) + (client.down || 0) >= client.totalGB * 1024 * 1024 * 1024;
const expired = client.expiryTime > 0 && client.expiryTime <= now;
return expired || exhausted;
},
generalActions(action) {
switch (action.key) {
case "resetClients":
this.resetAllClientTraffics();
break;
case "delDepletedClients":
this.delDepletedClients();
break;
}
},
resetAllClientTraffics() {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetAllClientTrafficTitle"}}',
content: '{{ i18n "pages.inbounds.resetAllClientTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
try {
const msg = await HttpUtil.post('/panel/client/resetAllTraffics');
if (msg.success) {
app.$message.success('{{ i18n "pages.inbounds.toasts.resetAllClientTrafficSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
} catch (e) {
console.error("Failed to reset all client traffics:", e);
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
});
},
resetClientTraffic(client) {
this.$confirm({
title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' ' + client.email,
content: '{{ i18n "pages.inbounds.resetTrafficContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "reset"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
try {
const msg = await HttpUtil.post('/panel/client/resetTraffic/' + client.id);
if (msg.success) {
app.$message.success('{{ i18n "pages.inbounds.toasts.resetInboundClientTrafficSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
} catch (e) {
console.error("Failed to reset client traffic:", e);
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
});
},
delDepletedClients() {
this.$confirm({
title: '{{ i18n "pages.inbounds.delDepletedClientsTitle"}}',
content: '{{ i18n "pages.inbounds.delDepletedClientsContent"}}',
class: themeSwitcher.currentTheme,
okText: '{{ i18n "delete"}}',
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
try {
const msg = await HttpUtil.post('/panel/client/delDepletedClients');
if (msg.success) {
app.$message.success('{{ i18n "pages.inbounds.toasts.delDepletedClientsSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
} catch (e) {
console.error("Failed to delete depleted clients:", e);
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
});
}
},
async mounted() {
@ -667,6 +870,8 @@
// Initial data fetch
this.loadClients().then(() => {
this.loading(false);
// Initialize searchedClients after first load
this.searchedClients = this.clients.slice();
});
// Setup WebSocket for real-time updates
@ -676,10 +881,11 @@
// Listen for inbounds updates (contains full client traffic data)
window.wsClient.on('inbounds', (payload) => {
if (payload && Array.isArray(payload)) {
// Reload clients to get updated traffic (silently, without showing loading spinner)
// Only reload if not already refreshing to avoid multiple simultaneous requests
// Update traffic for clients from inbounds data
// This is more efficient than reloading all clients
if (!this.refreshing) {
this.refreshing = true;
// Silently reload clients to get updated traffic
this.loadClients().finally(() => {
this.refreshing = false;
});
@ -725,7 +931,11 @@
this.startDataRefreshLoop();
}
}
},
watch: {
searchKey: Utils.debounce(function (newVal) {
this.searchClients(newVal);
}, 500)
}
});
</script>

View file

@ -12,13 +12,6 @@
<template slot="title">{{ i18n "info" }}</template>
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" :style="{ color: 'var(--color-primary-100)'}"></a-icon>
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon>
</a-popconfirm>
</a-tooltip>
<a-tooltip>
<template slot="title">
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
@ -156,10 +149,6 @@
<a-icon :style="{ fontSize: '14px' }" type="info-circle"></a-icon>
{{ i18n "info" }}
</a-menu-item>
<a-menu-item @click="resetClientTraffic(client,record.id)" v-if="client.email.length > 0">
<a-icon :style="{ fontSize: '14px' }" type="retweet"></a-icon>
{{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item v-if="isRemovable(record.id)" @click="delClient(record.id,client)">
<a-icon :style="{ fontSize: '14px' }" type="delete"></a-icon>
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>

View file

@ -39,6 +39,8 @@
<script>
const SIDEBAR_COLLAPSED_KEY = "isSidebarCollapsed"
// Get multiNodeMode from server-rendered template
const INITIAL_MULTI_NODE_MODE = {{ if .multiNodeMode }}true{{else}}false{{end}};
Vue.component('a-sidebar', {
data() {
@ -49,7 +51,7 @@
],
visible: false,
collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)),
multiNodeMode: false
multiNodeMode: INITIAL_MULTI_NODE_MODE
}
},
methods: {
@ -134,8 +136,7 @@
},
mounted() {
this.updateTabs();
this.loadMultiNodeMode();
// Watch for multi-node mode changes
// Watch for multi-node mode changes (update tabs if mode changes)
setInterval(() => {
this.loadMultiNodeMode();
}, 5000);

View file

@ -123,7 +123,7 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="client._totalGB" :min="0"></a-input-number>
<a-input-number v-model.number="client._totalGB" :min="0" :step="0.01" :precision="2"></a-input-number>
</a-form-item>
<a-form-item v-if="isEdit && clientStats" label='{{ i18n "usage" }}'>
<a-tag :color="ColorUtils.clientUsageColor(clientStats, app.trafficDiff)">

View file

@ -61,7 +61,7 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="dbInbound.totalGB" :min="0"></a-input-number>
<a-input-number v-model.number="dbInbound.totalGB" :min="0" :step="0.01" :precision="2"></a-input-number>
</a-form-item>
<a-form-item>

View file

@ -123,18 +123,6 @@
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export" }} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item>
<a-menu-item key="resetInbounds">
<a-icon type="reload"></a-icon>
{{ i18n "pages.inbounds.resetAllTraffic" }}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetAllClientTraffics" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</a-space>
@ -204,10 +192,6 @@
{{ i18n "qrCode" }}
</a-menu-item>
<template v-if="dbInbound.isMultiUser()">
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetInboundClientTraffics"}}
</a-menu-item>
<a-menu-item key="export">
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}}
@ -216,10 +200,6 @@
<a-icon type="export"></a-icon>
{{ i18n "pages.inbounds.export"}} - {{ i18n "pages.settings.subSettings" }}
</a-menu-item>
<a-menu-item key="delDepletedClients" :style="{ color: '#FF4D4F' }">
<a-icon type="rest"></a-icon>
{{ i18n "pages.inbounds.delDepletedClients" }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo">
@ -231,9 +211,6 @@
<a-icon type="copy"></a-icon>
{{ i18n "pages.inbounds.exportInbound" }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<a-icon type="retweet"></a-icon> {{ i18n "pages.inbounds.resetTraffic" }}
</a-menu-item>
<a-menu-item key="clone">
<a-icon type="block"></a-icon> {{ i18n "pages.inbounds.clone"}}
</a-menu-item>
@ -345,19 +322,12 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr>
<tr v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down) ]]</td>
</tr>
<!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
</table>
</template>
<a-tag
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<a-tag>
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
<template v-if="false">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
@ -367,9 +337,6 @@
</a-tag>
</a-popover>
</template>
<template slot="allTimeInbound" slot-scope="text, dbInbound">
<a-tag>[[ SizeFormatter.sizeFormat(dbInbound.allTime || 0) ]]</a-tag>
</template>
<template slot="enable" slot-scope="text, dbInbound">
<a-switch v-model="dbInbound.enable"
@change="switchEnable(dbInbound.id,dbInbound.enable)"></a-switch>
@ -508,21 +475,12 @@
<td>↑[[ SizeFormatter.sizeFormat(dbInbound.up) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(dbInbound.down) ]]</td>
</tr>
<tr
v-if="dbInbound.total > 0 && dbInbound.up + dbInbound.down < dbInbound.total">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(dbInbound.total - dbInbound.up - dbInbound.down)
]]</td>
</tr>
<!-- Inbound traffic is now only statistics (sum of client traffic), no limits -->
</table>
</template>
<a-tag
:color="ColorUtils.usageColor(dbInbound.up + dbInbound.down, app.trafficDiff, dbInbound.total)">
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]] /
<template v-if="dbInbound.total > 0">
[[ SizeFormatter.sizeFormat(dbInbound.total) ]]
</template>
<template v-else>
<a-tag>
[[ SizeFormatter.sizeFormat(dbInbound.up + dbInbound.down) ]]
<template v-if="false">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
@ -648,11 +606,6 @@
align: 'center',
width: 90,
scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'allTimeInbound' },
}, {
title: '{{ i18n "pages.inbounds.expireDate" }}',
align: 'center',
@ -689,7 +642,6 @@
{ title: '{{ i18n "online" }}', width: 32, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 60, align: 'center', scopedSlots: { customRender: 'allTime' } },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
];
@ -981,15 +933,6 @@
case "subs":
this.exportAllSubs();
break;
case "resetInbounds":
this.resetAllTraffic();
break;
case "resetClients":
this.resetAllClientTraffics(-1);
break;
case "delDepletedClients":
this.delDepletedClients(-1)
break;
}
},
clickAction(action, dbInbound) {
@ -1012,21 +955,12 @@
case "clipboard":
this.copy(dbInbound.id);
break;
case "resetTraffic":
this.resetTraffic(dbInbound.id);
break;
case "resetClients":
this.resetAllClientTraffics(dbInbound.id);
break;
case "clone":
this.openCloneInbound(dbInbound);
break;
case "delete":
this.delInbound(dbInbound.id);
break;
case "delDepletedClients":
this.delDepletedClients(dbInbound.id)
break;
}
},
openCloneInbound(dbInbound) {

View file

@ -85,7 +85,7 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0"></a-input-number>
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0" :step="0.01" :precision="2"></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>

View file

@ -41,7 +41,7 @@
<a-input-number v-model.number="client.limitIp" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.totalFlow" }} (GB)'>
<a-input-number v-model.number="client.totalGB" :min="0" :style="{ width: '100%' }"></a-input-number>
<a-input-number v-model.number="client.totalGB" :min="0" :step="0.01" :precision="2" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.expireDate" }}'>
<a-date-picker :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"

View file

@ -2,6 +2,8 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"time"
@ -37,21 +39,31 @@ func (s *ClientService) GetClients(userId int) ([]*model.ClientEntity, error) {
client.InboundIds = inboundIds
}
// Load traffic statistics from client_traffics table by email
var clientTraffic xray.ClientTraffic
err = db.Where("email = ?", strings.ToLower(client.Email)).First(&clientTraffic).Error
if err == nil {
// Traffic found - set traffic fields on client entity
client.Up = clientTraffic.Up
client.Down = clientTraffic.Down
client.AllTime = clientTraffic.AllTime
client.LastOnline = clientTraffic.LastOnline
// Note: expiryTime and totalGB are stored in ClientEntity, we don't override them from traffic
// Traffic table may have different values due to legacy data
} else if err != gorm.ErrRecordNotFound {
logger.Warningf("Failed to load traffic for client %s: %v", client.Email, err)
// Traffic statistics are now stored directly in ClientEntity table
// No need to load from client_traffics - fields are already loaded from DB
// Check if client exceeded limits and update status if needed (but keep Enable = true)
now := time.Now().Unix() * 1000
totalUsed := client.Up + client.Down
trafficLimit := int64(client.TotalGB * 1024 * 1024 * 1024)
trafficExceeded := client.TotalGB > 0 && totalUsed >= trafficLimit
timeExpired := client.ExpiryTime > 0 && client.ExpiryTime <= now
// Update status if expired, but don't change Enable
if trafficExceeded || timeExpired {
status := "expired_traffic"
if timeExpired {
status = "expired_time"
}
// Only update if status changed
if client.Status != status {
client.Status = status
err = db.Model(&model.ClientEntity{}).Where("id = ?", client.Id).Update("status", status).Error
if err != nil {
logger.Warningf("Failed to update status for client %s: %v", client.Email, err)
}
}
}
// If not found, traffic will be 0 (default values)
// Load HWIDs for this client
hwidService := ClientHWIDService{}
@ -67,6 +79,7 @@ func (s *ClientService) GetClients(userId int) ([]*model.ClientEntity, error) {
}
// GetClient retrieves a client by ID.
// Traffic statistics are now stored directly in ClientEntity table.
func (s *ClientService) GetClient(id int) (*model.ClientEntity, error) {
db := database.GetDB()
var client model.ClientEntity
@ -81,6 +94,9 @@ func (s *ClientService) GetClient(id int) (*model.ClientEntity, error) {
client.InboundIds = inboundIds
}
// Traffic statistics (Up, Down, AllTime, LastOnline) are already loaded from ClientEntity table
// No need to load from client_traffics
// Load HWIDs for this client
hwidService := ClientHWIDService{}
hwids, err := hwidService.GetHWIDsForClient(client.Id)
@ -170,37 +186,24 @@ func (s *ClientService) AddClient(userId int, client *model.ClientEntity) (bool,
}
}()
// Initialize traffic fields to 0 (they are stored in ClientEntity now)
client.Up = 0
client.Down = 0
client.AllTime = 0
client.LastOnline = 0
// Set default status to "active" if not specified
if client.Status == "" {
client.Status = "active"
}
err = tx.Create(client).Error
if err != nil {
return false, err
}
// Create initial ClientTraffic record if it doesn't exist
// This ensures traffic statistics are tracked from the start
var count int64
tx.Model(&xray.ClientTraffic{}).Where("email = ?", client.Email).Count(&count)
if count == 0 {
// Create traffic record for the first assigned inbound, or use 0 if no inbounds yet
inboundId := 0
if len(client.InboundIds) > 0 {
inboundId = client.InboundIds[0]
}
clientTraffic := xray.ClientTraffic{
InboundId: inboundId,
Email: client.Email,
Total: client.TotalGB,
ExpiryTime: client.ExpiryTime,
Enable: client.Enable,
Up: 0,
Down: 0,
Reset: client.Reset,
}
err = tx.Create(&clientTraffic).Error
if err != nil {
logger.Warningf("Failed to create ClientTraffic for client %s: %v", client.Email, err)
// Don't fail the whole operation if traffic record creation fails
}
}
// Traffic statistics are now stored directly in ClientEntity table
// No need to create separate client_traffics records
// Assign to inbounds if provided
if len(client.InboundIds) > 0 {
@ -317,13 +320,17 @@ func (s *ClientService) UpdateClient(userId int, client *model.ClientEntity) (bo
if client.LimitIP > 0 {
updates["limit_ip"] = client.LimitIP
}
if client.TotalGB > 0 {
// Always update TotalGB if it's different (including setting to 0 to remove limit)
if client.TotalGB != existing.TotalGB {
updates["total_gb"] = client.TotalGB
}
if client.ExpiryTime != 0 {
updates["expiry_time"] = client.ExpiryTime
}
updates["enable"] = client.Enable
if client.Status != "" {
updates["status"] = client.Status
}
if client.TgID > 0 {
updates["tg_id"] = client.TgID
}
@ -394,6 +401,32 @@ func (s *ClientService) UpdateClient(userId int, client *model.ClientEntity) (bo
}
}
// Traffic statistics are now stored directly in ClientEntity table
// No need to sync with client_traffics - all fields (TotalGB, ExpiryTime, Enable, Email) are in ClientEntity
// Check if client was expired and is now no longer expired (traffic reset or limit increased)
// Reload client to get updated values
var updatedClient model.ClientEntity
if err := tx.Where("id = ?", client.Id).First(&updatedClient).Error; err == nil {
wasExpired := existing.Status == "expired_traffic" || existing.Status == "expired_time"
// Check if client is no longer expired
now := time.Now().Unix() * 1000
totalUsed := updatedClient.Up + updatedClient.Down
trafficLimit := int64(updatedClient.TotalGB * 1024 * 1024 * 1024)
trafficExceeded := updatedClient.TotalGB > 0 && totalUsed >= trafficLimit
timeExpired := updatedClient.ExpiryTime > 0 && updatedClient.ExpiryTime <= now
// If client was expired but is no longer expired, reset status and re-add to Xray
if wasExpired && !trafficExceeded && !timeExpired && updatedClient.Enable {
updates["status"] = "active"
if err := tx.Model(&model.ClientEntity{}).Where("id = ?", client.Id).Update("status", "active").Error; err == nil {
updatedClient.Status = "active"
logger.Infof("Client %s is no longer expired, status reset to active", updatedClient.Email)
}
}
}
// Commit client transaction first to avoid nested transactions
err = tx.Commit().Error
committed = true
@ -406,6 +439,62 @@ func (s *ClientService) UpdateClient(userId int, client *model.ClientEntity) (bo
// We do this AFTER committing the client transaction to avoid nested transactions and database locks
needRestart := false
inboundService := InboundService{}
// Check if client needs to be re-added to Xray (was expired, now active)
wasExpired := existing.Status == "expired_traffic" || existing.Status == "expired_time"
nowActive := updatedClient.Status == "active" || updatedClient.Status == ""
if wasExpired && nowActive && updatedClient.Enable && p != nil {
// Re-add client to Xray API for all assigned inbounds
inboundService.xrayApi.Init(p.GetAPIPort())
defer inboundService.xrayApi.Close()
clientInboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil {
for _, inboundId := range clientInboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
continue
}
// Build client data for Xray API
clientData := make(map[string]any)
clientData["email"] = updatedClient.Email
switch inbound.Protocol {
case model.Trojan:
clientData["password"] = updatedClient.Password
case model.Shadowsocks:
var settings map[string]any
json.Unmarshal([]byte(inbound.Settings), &settings)
if method, ok := settings["method"].(string); ok {
clientData["method"] = method
}
clientData["password"] = updatedClient.Password
case model.VMESS, model.VLESS:
clientData["id"] = updatedClient.UUID
if inbound.Protocol == model.VMESS && updatedClient.Security != "" {
clientData["security"] = updatedClient.Security
}
if inbound.Protocol == model.VLESS && updatedClient.Flow != "" {
clientData["flow"] = updatedClient.Flow
}
}
err = inboundService.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, clientData)
if err != nil {
if strings.Contains(err.Error(), fmt.Sprintf("User %s already exists.", updatedClient.Email)) {
logger.Debugf("Client %s already exists in Xray (tag: %s)", updatedClient.Email, inbound.Tag)
} else {
logger.Warningf("Failed to re-add client %s to Xray (tag: %s): %v", updatedClient.Email, inbound.Tag, err)
needRestart = true
}
} else {
logger.Infof("Client %s re-added to Xray (tag: %s) after traffic reset", updatedClient.Email, inbound.Tag)
}
}
}
}
for inboundId := range affectedInboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
@ -589,7 +678,7 @@ func (s *ClientService) ConvertClientEntityToClient(entity *model.ClientEntity)
Flow: entity.Flow,
Email: entity.Email,
LimitIP: entity.LimitIP,
TotalGB: entity.TotalGB,
TotalGB: int64(entity.TotalGB), // Convert float64 to int64 for legacy compatibility (rounds down)
ExpiryTime: entity.ExpiryTime,
Enable: entity.Enable,
TgID: entity.TgID,
@ -600,3 +689,578 @@ func (s *ClientService) ConvertClientEntityToClient(entity *model.ClientEntity)
UpdatedAt: entity.UpdatedAt,
}
}
// ConvertClientToEntity converts legacy Client struct to ClientEntity.
func (s *ClientService) ConvertClientToEntity(client *model.Client, userId int) *model.ClientEntity {
status := "active"
if !client.Enable {
// If client is disabled, check if it's expired
now := time.Now().Unix() * 1000
totalUsed := int64(0) // We don't have traffic info here, assume 0
trafficLimit := int64(client.TotalGB * 1024 * 1024 * 1024)
trafficExceeded := client.TotalGB > 0 && totalUsed >= trafficLimit
timeExpired := client.ExpiryTime > 0 && client.ExpiryTime <= now
if trafficExceeded {
status = "expired_traffic"
} else if timeExpired {
status = "expired_time"
}
}
return &model.ClientEntity{
UserId: userId,
Email: strings.ToLower(client.Email),
UUID: client.ID,
Security: client.Security,
Password: client.Password,
Flow: client.Flow,
LimitIP: client.LimitIP,
TotalGB: float64(client.TotalGB), // Convert int64 to float64
ExpiryTime: client.ExpiryTime,
Enable: client.Enable,
Status: status,
TgID: client.TgID,
SubID: client.SubID,
Comment: client.Comment,
Reset: client.Reset,
CreatedAt: client.CreatedAt,
UpdatedAt: client.UpdatedAt,
}
}
// DisableClientsByEmail removes expired clients from Xray API and updates their status.
// This is called after AddClientTraffic marks clients as expired.
func (s *ClientService) DisableClientsByEmail(clientsToDisable map[string]string, inboundService *InboundService) (bool, error) {
if len(clientsToDisable) == 0 {
logger.Debugf("DisableClientsByEmail: no clients to disable")
return false, nil
}
if p == nil {
logger.Warningf("DisableClientsByEmail: p is nil, cannot remove clients from Xray")
return false, nil
}
logger.Infof("DisableClientsByEmail: removing %d expired clients from Xray", len(clientsToDisable))
db := database.GetDB()
needRestart := false
// Group clients by tag
tagClients := make(map[string][]string)
for email, tag := range clientsToDisable {
tagClients[tag] = append(tagClients[tag], email)
logger.Debugf("DisableClientsByEmail: client %s will be removed from tag %s", email, tag)
}
// Remove from Xray API
inboundService.xrayApi.Init(p.GetAPIPort())
defer inboundService.xrayApi.Close()
for tag, emails := range tagClients {
for _, email := range emails {
err := inboundService.xrayApi.RemoveUser(tag, email)
if err != nil {
if strings.Contains(err.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debugf("DisableClientsByEmail: client %s already removed from Xray (tag: %s)", email, tag)
} else {
logger.Warningf("DisableClientsByEmail: failed to remove client %s from Xray (tag: %s): %v", email, tag, err)
needRestart = true // If API removal fails, need restart
}
} else {
logger.Infof("DisableClientsByEmail: successfully removed client %s from Xray (tag: %s)", email, tag)
}
}
}
// Update client status in database (but keep Enable = true)
emails := make([]string, 0, len(clientsToDisable))
for email := range clientsToDisable {
emails = append(emails, email)
}
// Get clients and update their status
var clients []*model.ClientEntity
if err := db.Where("LOWER(email) IN (?)", emails).Find(&clients).Error; err == nil {
for _, client := range clients {
// Status should already be set by AddClientTraffic, but ensure it's set
if client.Status != "expired_traffic" && client.Status != "expired_time" {
// Determine status based on limits
now := time.Now().Unix() * 1000
totalUsed := client.Up + client.Down
trafficLimit := int64(client.TotalGB * 1024 * 1024 * 1024)
trafficExceeded := client.TotalGB > 0 && totalUsed >= trafficLimit
timeExpired := client.ExpiryTime > 0 && client.ExpiryTime <= now
if trafficExceeded {
client.Status = "expired_traffic"
} else if timeExpired {
client.Status = "expired_time"
}
}
}
db.Save(clients)
}
// Update inbound settings to remove expired clients
// Get all affected inbounds
allTags := make(map[string]bool)
for _, tag := range clientsToDisable {
allTags[tag] = true
}
for tag := range allTags {
var inbound model.Inbound
if err := db.Where("tag = ?", tag).First(&inbound).Error; err == nil {
logger.Debugf("DisableClientsByEmail: updating inbound %d (tag: %s) to remove expired clients", inbound.Id, tag)
// Rebuild settings without expired clients
allClients, err := s.GetClientsForInbound(inbound.Id)
if err == nil {
// Count expired clients before filtering
expiredCount := 0
for _, client := range allClients {
if client.Status == "expired_traffic" || client.Status == "expired_time" {
expiredCount++
}
}
logger.Debugf("DisableClientsByEmail: inbound %d has %d total clients, %d expired", inbound.Id, len(allClients), expiredCount)
newSettings, err := inboundService.BuildSettingsFromClientEntities(&inbound, allClients)
if err == nil {
inbound.Settings = newSettings
_, _, err = inboundService.updateInboundWithRetry(&inbound)
if err != nil {
logger.Warningf("DisableClientsByEmail: failed to update inbound %d: %v", inbound.Id, err)
needRestart = true
} else {
logger.Infof("DisableClientsByEmail: successfully updated inbound %d (tag: %s) without expired clients", inbound.Id, tag)
}
} else {
logger.Warningf("DisableClientsByEmail: failed to build settings for inbound %d: %v", inbound.Id, err)
}
} else {
logger.Warningf("DisableClientsByEmail: failed to get clients for inbound %d: %v", inbound.Id, err)
}
} else {
logger.Warningf("DisableClientsByEmail: failed to find inbound with tag %s: %v", tag, err)
}
}
return needRestart, nil
}
// ResetAllClientTraffics resets traffic counters for all clients of a specific user.
// Returns whether Xray needs restart and any error.
func (s *ClientService) ResetAllClientTraffics(userId int) (bool, error) {
db := database.GetDB()
// Get all clients that were expired due to traffic before reset
var expiredClients []model.ClientEntity
err := db.Where("user_id = ? AND status = ?", userId, "expired_traffic").Find(&expiredClients).Error
if err != nil {
return false, err
}
// Reset traffic for all clients of this user in ClientEntity table
result := db.Model(&model.ClientEntity{}).
Where("user_id = ?", userId).
Updates(map[string]interface{}{
"up": 0,
"down": 0,
"all_time": 0,
})
if result.Error != nil {
return false, result.Error
}
// Reset status to "active" for clients expired due to traffic
// This will allow clients to be re-added to Xray if they were removed
db.Model(&model.ClientEntity{}).
Where("user_id = ? AND status = ?", userId, "expired_traffic").
Update("status", "active")
// Re-add expired clients to Xray if they were removed
needRestart := false
if len(expiredClients) > 0 && p != nil {
inboundService := InboundService{}
inboundService.xrayApi.Init(p.GetAPIPort())
defer inboundService.xrayApi.Close()
// Group clients by inbound
inboundClients := make(map[int][]model.ClientEntity)
for _, client := range expiredClients {
inboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil {
for _, inboundId := range inboundIds {
inboundClients[inboundId] = append(inboundClients[inboundId], client)
}
}
}
// Re-add clients to Xray for each inbound
for inboundId, clients := range inboundClients {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
continue
}
// Get method for shadowsocks
var method string
if inbound.Protocol == model.Shadowsocks {
var settings map[string]any
json.Unmarshal([]byte(inbound.Settings), &settings)
if m, ok := settings["method"].(string); ok {
method = m
}
}
for _, client := range clients {
if !client.Enable {
continue
}
// Build client data for Xray API
clientData := make(map[string]any)
clientData["email"] = client.Email
switch inbound.Protocol {
case model.Trojan:
clientData["password"] = client.Password
case model.Shadowsocks:
if method != "" {
clientData["method"] = method
}
clientData["password"] = client.Password
case model.VMESS, model.VLESS:
clientData["id"] = client.UUID
if inbound.Protocol == model.VMESS && client.Security != "" {
clientData["security"] = client.Security
}
if inbound.Protocol == model.VLESS && client.Flow != "" {
clientData["flow"] = client.Flow
}
}
err := inboundService.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, clientData)
if err != nil {
if strings.Contains(err.Error(), fmt.Sprintf("User %s already exists.", client.Email)) {
logger.Debugf("Client %s already exists in Xray (tag: %s)", client.Email, inbound.Tag)
} else {
logger.Warningf("Failed to re-add client %s to Xray (tag: %s): %v", client.Email, inbound.Tag, err)
needRestart = true
}
} else {
logger.Infof("Client %s re-added to Xray (tag: %s) after traffic reset", client.Email, inbound.Tag)
}
}
// Update inbound settings to include all clients
allClients, err := s.GetClientsForInbound(inboundId)
if err == nil {
newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, allClients)
if err == nil {
inbound.Settings = newSettings
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
if err != nil {
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
} else if inboundNeedRestart {
needRestart = true
}
}
}
}
}
return needRestart, nil
}
// ResetClientTraffic resets traffic counter for a specific client.
// Returns whether Xray needs restart and any error.
func (s *ClientService) ResetClientTraffic(userId int, clientId int) (bool, error) {
db := database.GetDB()
// Get client and verify ownership
client, err := s.GetClient(clientId)
if err != nil {
return false, err
}
if client.UserId != userId {
return false, common.NewError("Client not found or access denied")
}
// Check if client was expired due to traffic
wasExpired := client.Status == "expired_traffic" || client.Status == "expired_time"
// Reset traffic in ClientEntity
result := db.Model(&model.ClientEntity{}).
Where("id = ? AND user_id = ?", clientId, userId).
Updates(map[string]interface{}{
"up": 0,
"down": 0,
"all_time": 0,
})
if result.Error != nil {
return false, result.Error
}
// Reset status to "active" if client was expired due to traffic
if wasExpired {
db.Model(&model.ClientEntity{}).
Where("id = ? AND user_id = ?", clientId, userId).
Update("status", "active")
}
// Re-add client to Xray if it was expired and is now active
needRestart := false
if wasExpired && client.Enable && p != nil {
inboundService := InboundService{}
inboundService.xrayApi.Init(p.GetAPIPort())
defer inboundService.xrayApi.Close()
// Get all inbounds for this client
inboundIds, err := s.GetInboundIdsForClient(clientId)
if err == nil {
for _, inboundId := range inboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
continue
}
// Build client data for Xray API
clientData := make(map[string]any)
clientData["email"] = client.Email
switch inbound.Protocol {
case model.Trojan:
clientData["password"] = client.Password
case model.Shadowsocks:
var settings map[string]any
json.Unmarshal([]byte(inbound.Settings), &settings)
if method, ok := settings["method"].(string); ok {
clientData["method"] = method
}
clientData["password"] = client.Password
case model.VMESS, model.VLESS:
clientData["id"] = client.UUID
if inbound.Protocol == model.VMESS && client.Security != "" {
clientData["security"] = client.Security
}
if inbound.Protocol == model.VLESS && client.Flow != "" {
clientData["flow"] = client.Flow
}
}
err = inboundService.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, clientData)
if err != nil {
if strings.Contains(err.Error(), fmt.Sprintf("User %s already exists.", client.Email)) {
logger.Debugf("Client %s already exists in Xray (tag: %s)", client.Email, inbound.Tag)
} else {
logger.Warningf("Failed to re-add client %s to Xray (tag: %s): %v", client.Email, inbound.Tag, err)
needRestart = true
}
} else {
logger.Infof("Client %s re-added to Xray (tag: %s) after traffic reset", client.Email, inbound.Tag)
}
}
}
// Update inbound settings to include the client
for _, inboundId := range inboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
continue
}
// Get all clients for this inbound
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
continue
}
// Rebuild Settings from ClientEntity
newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities)
if err != nil {
continue
}
// Update inbound Settings
inbound.Settings = newSettings
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
if err != nil {
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
} else if inboundNeedRestart {
needRestart = true
}
}
}
return needRestart, nil
}
// DelDepletedClients deletes clients that have exhausted their traffic limits or expired.
// Returns the number of deleted clients, whether Xray needs restart, and any error.
func (s *ClientService) DelDepletedClients(userId int) (int, bool, error) {
db := database.GetDB()
now := time.Now().Unix() * 1000
// Get all clients for this user
var clients []model.ClientEntity
err := db.Where("user_id = ?", userId).Find(&clients).Error
if err != nil {
return 0, false, err
}
if len(clients) == 0 {
return 0, false, nil
}
emails := make([]string, len(clients))
for i, client := range clients {
emails[i] = strings.ToLower(client.Email)
}
// Find depleted client traffics
var depletedTraffics []xray.ClientTraffic
err = db.Model(&xray.ClientTraffic{}).
Where("email IN (?) AND ((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?))", emails, now).
Find(&depletedTraffics).Error
if err != nil {
return 0, false, err
}
if len(depletedTraffics) == 0 {
return 0, false, nil
}
// Get emails of depleted clients
depletedEmails := make([]string, len(depletedTraffics))
for i, traffic := range depletedTraffics {
depletedEmails[i] = traffic.Email
}
// Get client IDs to delete
var clientIdsToDelete []int
err = db.Model(&model.ClientEntity{}).
Where("user_id = ? AND LOWER(email) IN (?)", userId, depletedEmails).
Pluck("id", &clientIdsToDelete).Error
if err != nil {
return 0, false, err
}
if len(clientIdsToDelete) == 0 {
return 0, false, nil
}
// Delete clients and their mappings
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Delete client-inbound mappings
err = tx.Where("client_id IN (?)", clientIdsToDelete).Delete(&model.ClientInboundMapping{}).Error
if err != nil {
return 0, false, err
}
// Delete client traffic records
err = tx.Where("email IN (?)", depletedEmails).Delete(&xray.ClientTraffic{}).Error
if err != nil {
return 0, false, err
}
// Delete clients
err = tx.Where("id IN (?) AND user_id = ?", clientIdsToDelete, userId).Delete(&model.ClientEntity{}).Error
if err != nil {
return 0, false, err
}
// Commit transaction before rebuilding inbounds (to avoid nested transactions)
err = tx.Commit().Error
if err != nil {
return 0, false, err
}
// Rebuild Settings for all affected inbounds
needRestart := false
inboundService := InboundService{}
// Get all unique inbound IDs that had these clients (from committed data)
var affectedInboundIds []int
err = db.Model(&model.ClientInboundMapping{}).
Where("client_id IN (?)", clientIdsToDelete).
Distinct("inbound_id").
Pluck("inbound_id", &affectedInboundIds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return 0, false, err
}
// Also check from client_traffics for backward compatibility (before deletion)
// Note: This query runs after deletion, so we need to get inbound IDs from depleted traffics before deletion
var trafficInboundIds []int
for _, traffic := range depletedTraffics {
if traffic.InboundId > 0 {
// Check if already in list
found := false
for _, id := range trafficInboundIds {
if id == traffic.InboundId {
found = true
break
}
}
if !found {
trafficInboundIds = append(trafficInboundIds, traffic.InboundId)
}
}
}
// Merge inbound IDs
inboundIdSet := make(map[int]bool)
for _, id := range affectedInboundIds {
inboundIdSet[id] = true
}
for _, id := range trafficInboundIds {
if !inboundIdSet[id] {
affectedInboundIds = append(affectedInboundIds, id)
}
}
// Rebuild Settings for each affected inbound
for _, inboundId := range affectedInboundIds {
var inbound model.Inbound
err = db.First(&inbound, inboundId).Error
if err != nil {
continue
}
// Get all remaining clients for this inbound (from ClientEntity)
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
continue
}
// Rebuild Settings from ClientEntity
newSettings, err := inboundService.BuildSettingsFromClientEntities(&inbound, clientEntities)
if err != nil {
logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err)
continue
}
// Update inbound Settings
inbound.Settings = newSettings
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(&inbound)
if err != nil {
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
continue
} else if inboundNeedRestart {
needRestart = true
}
}
return len(clientIdsToDelete), needRestart, nil
}

View file

@ -0,0 +1,320 @@
// Package service provides Client traffic management service.
package service
import (
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm"
)
// AddClientTraffic updates client traffic statistics and returns clients that need to be disabled.
// This method handles traffic tracking for clients in the new architecture (ClientEntity).
// After updating client traffic, it synchronizes inbound traffic as the sum of all its clients' traffic.
func (s *ClientService) AddClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic, inboundService *InboundService) (map[string]string, map[int]bool, error) {
clientsToDisable := make(map[string]string) // map[email]tag
affectedInboundIds := make(map[int]bool) // Track affected inbounds for traffic sync
if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return clientsToDisable, affectedInboundIds, nil
}
onlineClients := make([]string, 0)
// Group traffic by email (aggregate traffic from all inbounds for each client)
emailTrafficMap := make(map[string]struct {
Up int64
Down int64
InboundIds []int
})
for _, traffic := range traffics {
email := strings.ToLower(traffic.Email)
existing := emailTrafficMap[email]
existing.Up += traffic.Up
existing.Down += traffic.Down
// Track all inbound IDs for this email
if traffic.InboundId > 0 {
found := false
for _, id := range existing.InboundIds {
if id == traffic.InboundId {
found = true
break
}
}
if !found {
existing.InboundIds = append(existing.InboundIds, traffic.InboundId)
affectedInboundIds[traffic.InboundId] = true
}
}
emailTrafficMap[email] = existing
}
// Get all unique emails
emails := make([]string, 0, len(emailTrafficMap))
for email := range emailTrafficMap {
emails = append(emails, email)
}
if len(emails) == 0 {
return clientsToDisable, affectedInboundIds, nil
}
// Load ClientEntity records for these emails
var clientEntities []*model.ClientEntity
err := tx.Model(&model.ClientEntity{}).Where("LOWER(email) IN (?)", emails).Find(&clientEntities).Error
if err != nil {
return nil, nil, err
}
// Get inbound tags for clients that need to be disabled
inboundIdMap := make(map[int]string) // map[inboundId]tag
if len(affectedInboundIds) > 0 {
inboundIdList := make([]int, 0, len(affectedInboundIds))
for id := range affectedInboundIds {
inboundIdList = append(inboundIdList, id)
}
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIdList).Find(&inbounds).Error
if err == nil {
for _, inbound := range inbounds {
inboundIdMap[inbound.Id] = inbound.Tag
}
}
}
now := time.Now().Unix() * 1000
// Update traffic for each client
for _, client := range clientEntities {
email := strings.ToLower(client.Email)
trafficData, ok := emailTrafficMap[email]
if !ok {
continue
}
// Check limits BEFORE adding traffic
currentUsed := client.Up + client.Down
newUp := trafficData.Up
newDown := trafficData.Down
newTotal := newUp + newDown
// Check if time is already expired
timeExpired := client.ExpiryTime > 0 && client.ExpiryTime <= now
// Check if adding this traffic would exceed the limit
trafficLimit := int64(client.TotalGB * 1024 * 1024 * 1024)
if client.TotalGB > 0 && trafficLimit > 0 {
remaining := trafficLimit - currentUsed
if remaining <= 0 {
// Already exceeded, don't add any traffic
newUp = 0
newDown = 0
newTotal = 0
} else if newTotal > remaining {
// Would exceed, add only up to the limit
allowedTraffic := remaining
// Proportionally distribute allowed traffic between up and down
if newTotal > 0 {
ratio := float64(allowedTraffic) / float64(newTotal)
newUp = int64(float64(newUp) * ratio)
newDown = int64(float64(newDown) * ratio)
newTotal = allowedTraffic
} else {
newUp = 0
newDown = 0
newTotal = 0
}
}
}
// Add traffic (may be reduced if limit would be exceeded)
// Note: ClientTraffic.Up = uplink (server→client) = Download for client
// ClientTraffic.Down = downlink (client→server) = Upload for client
// So we swap them when saving to ClientEntity to match client perspective
client.Up += newDown // Upload (client→server) goes to Up
client.Down += newUp // Download (server→client) goes to Down
client.AllTime += newTotal
// Check final state after adding traffic
finalUsed := client.Up + client.Down
finalTrafficExceeded := client.TotalGB > 0 && finalUsed >= trafficLimit
// Mark client with expired status if limit exceeded or time expired
if (finalTrafficExceeded || timeExpired) && client.Enable {
// Update status if not already set or if reason changed
shouldUpdateStatus := false
if finalTrafficExceeded && client.Status != "expired_traffic" {
client.Status = "expired_traffic"
shouldUpdateStatus = true
} else if timeExpired && client.Status != "expired_time" {
client.Status = "expired_time"
shouldUpdateStatus = true
}
// Only add to disable list if status was just set (not already expired)
// This prevents repeated attempts to remove already-removed clients
if shouldUpdateStatus {
// Mark for removal from Xray API - get all inbound IDs for this client
clientInboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil && len(clientInboundIds) > 0 {
// Try to find tag from inboundIdMap first (from traffic data)
found := false
for _, inboundId := range clientInboundIds {
if tag, ok := inboundIdMap[inboundId]; ok {
clientsToDisable[client.Email] = tag
found = true
break
}
}
// If not found in map, query database for tag
if !found {
var inbound model.Inbound
if err := tx.Model(&model.Inbound{}).Where("id = ?", clientInboundIds[0]).First(&inbound).Error; err == nil {
clientsToDisable[client.Email] = inbound.Tag
}
}
}
logger.Infof("Client %s marked with status %s: trafficExceeded=%v, timeExpired=%v, currentUsed=%d, newTraffic=%d, finalUsed=%d, total=%d",
client.Email, client.Status, finalTrafficExceeded, timeExpired, currentUsed, newTotal, finalUsed, trafficLimit)
}
}
// Add user in onlineUsers array on traffic (only if not disabled)
if newTotal > 0 && client.Enable {
onlineClients = append(onlineClients, client.Email)
client.LastOnline = time.Now().UnixMilli()
}
}
// Set onlineUsers
if p != nil {
p.SetOnlineClients(onlineClients)
}
// Save client entities with retry logic for database lock errors
maxRetries := 3
baseDelay := 10 * time.Millisecond
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
delay := baseDelay * time.Duration(1<<uint(attempt-1))
logger.Debugf("Retrying Save client entities (attempt %d/%d) after %v", attempt+1, maxRetries, delay)
time.Sleep(delay)
}
err = tx.Save(clientEntities).Error
if err == nil {
break
}
// Check if error is "database is locked"
errStr := err.Error()
if strings.Contains(errStr, "database is locked") || strings.Contains(errStr, "locked") {
if attempt < maxRetries-1 {
logger.Debugf("Database locked when saving client entities, will retry: %v", err)
continue
}
// Last attempt failed
logger.Warningf("Failed to save client entities after %d retries: %v", maxRetries, err)
return nil, nil, err
}
// For other errors, don't retry
logger.Warning("AddClientTraffic update data ", err)
return nil, nil, err
}
// Synchronize inbound traffic as sum of all its clients' traffic
// IMPORTANT: Sync ALL inbounds, not just affected ones, to ensure accurate totals
if inboundService != nil {
// Get all inbounds to sync their traffic
allInbounds, err := inboundService.GetAllInbounds()
if err == nil {
allInboundIds := make(map[int]bool)
for _, inbound := range allInbounds {
allInboundIds[inbound.Id] = true
}
err = s.syncInboundTrafficFromClients(tx, allInboundIds, inboundService)
if err != nil {
logger.Warningf("Failed to sync inbound traffic from clients: %v", err)
// Don't fail the whole operation, but log the warning
}
} else {
logger.Warningf("Failed to get all inbounds for traffic sync: %v", err)
// Fallback: sync only affected inbounds
err = s.syncInboundTrafficFromClients(tx, affectedInboundIds, inboundService)
if err != nil {
logger.Warningf("Failed to sync affected inbound traffic: %v", err)
}
}
}
return clientsToDisable, affectedInboundIds, nil
}
// syncInboundTrafficFromClients synchronizes inbound traffic as the sum of all its clients' traffic.
// This ensures that inbound traffic always equals the sum of all its clients' traffic.
// Traffic is now stored in ClientEntity, so we sum traffic from all enabled clients assigned to each inbound.
func (s *ClientService) syncInboundTrafficFromClients(tx *gorm.DB, inboundIds map[int]bool, inboundService *InboundService) error {
if len(inboundIds) == 0 {
return nil
}
inboundIdList := make([]int, 0, len(inboundIds))
for id := range inboundIds {
inboundIdList = append(inboundIdList, id)
}
// For each inbound, get all its clients and sum their traffic
for _, inboundId := range inboundIdList {
// Get all clients assigned to this inbound
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
continue
}
// Sum traffic from ALL clients (both enabled and disabled) for inbound statistics
// This ensures inbound traffic reflects total usage, not just active clients
var totalUp int64
var totalDown int64
var totalAllTime int64
enabledClientCount := 0
totalClientCount := len(clientEntities)
for _, client := range clientEntities {
// Sum traffic from all clients (enabled and disabled) for statistics
totalUp += client.Up
totalDown += client.Down
totalAllTime += client.AllTime
if client.Enable {
enabledClientCount++
}
}
// Update inbound traffic
err = tx.Model(&model.Inbound{}).Where("id = ?", inboundId).
Updates(map[string]any{
"up": totalUp,
"down": totalDown,
"all_time": totalAllTime,
}).Error
if err != nil {
logger.Warningf("Failed to sync inbound %d traffic: %v", inboundId, err)
continue
}
logger.Debugf("Synced inbound %d traffic: up=%d, down=%d, all_time=%d (from %d total clients, %d enabled)",
inboundId, totalUp, totalDown, totalAllTime, totalClientCount, enabledClientCount)
}
return nil
}

View file

@ -213,32 +213,20 @@ func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (
}
// GetClients retrieves clients for an inbound.
// First tries to get clients from ClientEntity (new approach),
// falls back to parsing Settings JSON for backward compatibility.
// Always uses ClientEntity (new architecture).
func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
clientService := ClientService{}
// Try to get clients from ClientEntity (new approach)
// Get clients from ClientEntity (new architecture)
clientEntities, err := clientService.GetClientsForInbound(inbound.Id)
if err == nil && len(clientEntities) > 0 {
// Convert ClientEntity to Client
clients := make([]model.Client, len(clientEntities))
for i, entity := range clientEntities {
clients[i] = clientService.ConvertClientEntityToClient(entity)
}
return clients, nil
if err != nil {
return nil, err
}
// Fallback: parse from Settings JSON (backward compatibility)
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
if settings == nil {
return nil, fmt.Errorf("setting is null")
}
clients := settings["clients"]
if clients == nil {
return nil, nil
// Convert ClientEntity to Client
clients := make([]model.Client, len(clientEntities))
for i, entity := range clientEntities {
clients[i] = clientService.ConvertClientEntityToClient(entity)
}
return clients, nil
}
@ -258,8 +246,9 @@ func (s *InboundService) BuildSettingsFromClientEntities(inbound *model.Inbound,
// Build clients array for Xray (only minimal fields)
var xrayClients []map[string]any
for _, entity := range clientEntities {
if !entity.Enable {
continue // Skip disabled clients
// Skip disabled clients or clients with expired status
if !entity.Enable || entity.Status == "expired_traffic" || entity.Status == "expired_time" {
continue
}
client := make(map[string]any)
@ -302,25 +291,12 @@ func (s *InboundService) getAllEmails() ([]string, error) {
db := database.GetDB()
var emails []string
// Get emails from ClientEntity (new approach)
// Get emails from ClientEntity (new architecture only)
err := db.Model(&model.ClientEntity{}).Pluck("email", &emails).Error
if err != nil {
return nil, err
}
// Also get emails from Settings JSON (backward compatibility)
var settingsEmails []string
_ = db.Raw(`
SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE JSON_EXTRACT(client.value, '$.email') IS NOT NULL
AND JSON_EXTRACT(client.value, '$.email') != ''
`).Scan(&settingsEmails)
if len(settingsEmails) > 0 {
emails = append(emails, settingsEmails...)
}
return emails, nil
}
@ -460,15 +436,12 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
}()
err = tx.Save(inbound).Error
if err == nil {
if len(inbound.ClientStats) == 0 {
for _, client := range clients {
s.AddClientStat(tx, inbound.Id, &client)
}
}
} else {
if err != nil {
return inbound, false, err
}
// Note: ClientStats are no longer managed here - clients are managed through ClientEntity
// Traffic is stored directly in ClientEntity table
needRestart := false
if inbound.Enable {
@ -601,10 +574,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
}
}()
err = s.updateClientTraffics(tx, oldInbound, inbound)
if err != nil {
return inbound, false, err
}
// updateClientTraffics is no longer needed - clients are managed through ClientEntity
// Settings JSON is generated from ClientEntity via BuildSettingsFromClientEntities
// No need to sync client_traffics as traffic is stored directly in ClientEntity
// Ensure created_at and updated_at exist in inbound.Settings clients
{
@ -686,15 +658,25 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
needRestart := false
s.xrayApi.Init(p.GetAPIPort())
defer s.xrayApi.Close()
// Always delete old inbound first to ensure clean state
// This is critical when removing disabled clients - we need to completely remove and recreate
if s.xrayApi.DelInbound(tag) == nil {
logger.Debug("Old inbound deleted by api:", tag)
} else {
logger.Debug("Failed to delete old inbound by api (may not exist):", tag)
// Continue anyway - inbound might not exist yet
}
if inbound.Enable {
// Generate new config with updated Settings (which excludes disabled clients)
inboundJson, err2 := json.MarshalIndent(oldInbound.GenXrayInboundConfig(), "", " ")
if err2 != nil {
logger.Debug("Unable to marshal updated inbound config:", err2)
needRestart = true
} else {
// Add new inbound with updated config (disabled clients are already excluded from Settings)
err2 = s.xrayApi.AddInbound(inboundJson)
if err2 == nil {
logger.Debug("Updated inbound added by api:", oldInbound.Tag)
@ -703,95 +685,35 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
needRestart = true
}
}
} else {
// Inbound is disabled - it's already deleted, nothing to add
logger.Debug("Inbound is disabled, not adding to Xray:", tag)
}
s.xrayApi.Close()
return inbound, needRestart, tx.Save(oldInbound).Error
}
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
oldClients, err := s.GetClients(oldInbound)
if err != nil {
return err
}
newClients, err := s.GetClients(newInbound)
if err != nil {
return err
}
var emailExists bool
for _, oldClient := range oldClients {
emailExists = false
for _, newClient := range newClients {
if oldClient.Email == newClient.Email {
emailExists = true
break
}
}
if !emailExists {
err = s.DelClientStat(tx, oldClient.Email)
if err != nil {
return err
}
}
}
for _, newClient := range newClients {
emailExists = false
for _, oldClient := range oldClients {
if newClient.Email == oldClient.Email {
emailExists = true
break
}
}
if !emailExists {
err = s.AddClientStat(tx, oldInbound.Id, &newClient)
if err != nil {
return err
}
}
}
return nil
}
// updateClientTraffics is removed - clients are now managed through ClientEntity
// Traffic is stored directly in ClientEntity table, no need to sync with client_traffics
func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
// Get clients from new data (these are the clients to add)
clients, err := s.GetClients(data)
if err != nil {
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(data.Settings), &settings)
if err != nil {
return false, err
}
interfaceClients := settings["clients"].([]any)
// Add timestamps for new clients being appended
nowTs := time.Now().Unix() * 1000
for i := range interfaceClients {
if cm, ok := interfaceClients[i].(map[string]any); ok {
if _, ok2 := cm["created_at"]; !ok2 {
cm["created_at"] = nowTs
}
cm["updated_at"] = nowTs
interfaceClients[i] = cm
}
}
existEmail, err := s.checkEmailsExistForClients(clients)
if err != nil {
return false, err
}
if existEmail != "" {
return false, common.NewError("Duplicate email:", existEmail)
if len(clients) == 0 {
return false, common.NewError("No clients to add")
}
// Get inbound to get userId
oldInbound, err := s.GetInbound(data.Id)
if err != nil {
return false, err
}
// Secure client ID
// Validate client IDs
for _, client := range clients {
switch oldInbound.Protocol {
case "trojan":
@ -809,82 +731,55 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
// Check for duplicate emails
existEmail, err := s.checkEmailsExistForClients(clients)
if err != nil {
return false, err
}
oldClients := oldSettings["clients"].([]any)
oldClients = append(oldClients, interfaceClients...)
oldSettings["clients"] = oldClients
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
if err != nil {
return false, err
if existEmail != "" {
return false, common.NewError("Duplicate email:", existEmail)
}
oldInbound.Settings = string(newSettings)
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Use ClientService to add clients
clientService := ClientService{}
needRestart := false
s.xrayApi.Init(p.GetAPIPort())
// Add each client using ClientService
for _, client := range clients {
if len(client.Email) > 0 {
s.AddClientStat(tx, data.Id, &client)
if client.Enable {
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
}
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client added by api:", client.Email)
} else {
logger.Debug("Error in adding client by api:", err1)
needRestart = true
}
}
} else {
// Convert Client to ClientEntity
clientEntity := clientService.ConvertClientToEntity(&client, oldInbound.UserId)
// Set inbound assignment
clientEntity.InboundIds = []int{data.Id}
// Add client using ClientService (this handles Settings update automatically)
clientNeedRestart, err := clientService.AddClient(oldInbound.UserId, clientEntity)
if err != nil {
return false, err
}
if clientNeedRestart {
needRestart = true
}
}
s.xrayApi.Close()
return needRestart, tx.Save(oldInbound).Error
return needRestart, nil
}
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
// Get inbound to find the client
oldInbound, err := s.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &settings)
// Get all clients for this inbound (from ClientEntity)
oldClients, err := s.GetClients(oldInbound)
if err != nil {
return false, err
}
email := ""
// Find client by clientId (UUID/password/email depending on protocol)
var targetEmail string
client_key := "id"
if oldInbound.Protocol == "trojan" {
client_key = "password"
@ -893,128 +788,105 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
client_key = "email"
}
interfaceClients := settings["clients"].([]any)
var newClients []any
needApiDel := false
for _, client := range interfaceClients {
c := client.(map[string]any)
c_id := c[client_key].(string)
for _, client := range oldClients {
var c_id string
switch client_key {
case "password":
c_id = client.Password
case "email":
c_id = client.Email
default:
c_id = client.ID
}
if c_id == clientId {
email, _ = c["email"].(string)
needApiDel, _ = c["enable"].(bool)
} else {
newClients = append(newClients, client)
targetEmail = client.Email
break
}
}
if len(newClients) == 0 {
if targetEmail == "" {
return false, common.NewError("Client not found")
}
// Find ClientEntity by email
clientService := ClientService{}
clientEntity, err := clientService.GetClientByEmail(oldInbound.UserId, targetEmail)
if err != nil {
return false, common.NewError("ClientEntity not found")
}
// Check if this is the only client in the inbound
if len(oldClients) <= 1 {
return false, common.NewError("no client remained in Inbound")
}
settings["clients"] = newClients
newSettings, err := json.MarshalIndent(settings, "", " ")
// Delete client using ClientService (this handles Settings update automatically)
needRestart, err := clientService.DeleteClient(oldInbound.UserId, clientEntity.Id)
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
db := database.GetDB()
err = s.DelClientIPs(db, email)
if err != nil {
logger.Error("Error in delete client IPs")
return false, err
}
needRestart := false
if len(email) > 0 {
notDepleted := true
err = db.Model(xray.ClientTraffic{}).Select("enable").Where("email = ?", email).First(&notDepleted).Error
if err != nil {
logger.Error("Get stats error")
return false, err
}
err = s.DelClientStat(db, email)
if err != nil {
logger.Error("Delete stats Data Error")
return false, err
}
if needApiDel && notDepleted {
s.xrayApi.Init(p.GetAPIPort())
err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email)
if err1 == nil {
logger.Debug("Client deleted by api:", email)
needRestart = false
} else {
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client by api:", err1)
needRestart = true
}
}
s.xrayApi.Close()
}
}
return needRestart, db.Save(oldInbound).Error
return needRestart, nil
}
func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId string) (bool, error) {
// TODO: check if TrafficReset field is updating
clients, err := s.GetClients(data)
// Get new client data
newClients, err := s.GetClients(data)
if err != nil {
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(data.Settings), &settings)
if err != nil {
return false, err
if len(newClients) == 0 {
return false, common.NewError("No client data provided")
}
interfaceClients := settings["clients"].([]any)
newClient := newClients[0]
// Get inbound to find the old client
oldInbound, err := s.GetInbound(data.Id)
if err != nil {
return false, err
}
// Get all clients for this inbound (from ClientEntity)
oldClients, err := s.GetClients(oldInbound)
if err != nil {
return false, err
}
oldEmail := ""
newClientId := ""
clientIndex := -1
for index, oldClient := range oldClients {
oldClientId := ""
switch oldInbound.Protocol {
case "trojan":
// Find old client by clientId (UUID/password/email depending on protocol)
var oldEmail string
client_key := "id"
if oldInbound.Protocol == "trojan" {
client_key = "password"
}
if oldInbound.Protocol == "shadowsocks" {
client_key = "email"
}
for _, oldClient := range oldClients {
var oldClientId string
switch client_key {
case "password":
oldClientId = oldClient.Password
newClientId = clients[0].Password
case "shadowsocks":
case "email":
oldClientId = oldClient.Email
newClientId = clients[0].Email
default:
oldClientId = oldClient.ID
newClientId = clients[0].ID
}
if clientId == oldClientId {
oldEmail = oldClient.Email
clientIndex = index
break
}
}
// Validate new client ID
if newClientId == "" || clientIndex == -1 {
return false, common.NewError("empty client ID")
if oldEmail == "" {
return false, common.NewError("Client not found")
}
if len(clients[0].Email) > 0 && clients[0].Email != oldEmail {
existEmail, err := s.checkEmailsExistForClients(clients)
// Check for duplicate email if email changed
if newClient.Email != "" && strings.ToLower(newClient.Email) != strings.ToLower(oldEmail) {
existEmail, err := s.checkEmailsExistForClients(newClients)
if err != nil {
return false, err
}
@ -1023,116 +895,28 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
// Find ClientEntity by old email
clientService := ClientService{}
clientEntity, err := clientService.GetClientByEmail(oldInbound.UserId, oldEmail)
if err != nil {
return false, err
return false, common.NewError("ClientEntity not found")
}
settingsClients := oldSettings["clients"].([]any)
// Preserve created_at and set updated_at for the replacing client
var preservedCreated any
if clientIndex >= 0 && clientIndex < len(settingsClients) {
if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok {
if v, ok2 := oldMap["created_at"]; ok2 {
preservedCreated = v
}
}
}
if len(interfaceClients) > 0 {
if newMap, ok := interfaceClients[0].(map[string]any); ok {
if preservedCreated == nil {
preservedCreated = time.Now().Unix() * 1000
}
newMap["created_at"] = preservedCreated
newMap["updated_at"] = time.Now().Unix() * 1000
interfaceClients[0] = newMap
}
}
settingsClients[clientIndex] = interfaceClients[0]
oldSettings["clients"] = settingsClients
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
// Convert new Client to ClientEntity and update
updatedEntity := clientService.ConvertClientToEntity(&newClient, oldInbound.UserId)
updatedEntity.Id = clientEntity.Id
// Preserve created_at
updatedEntity.CreatedAt = clientEntity.CreatedAt
// Preserve inbound assignments
updatedEntity.InboundIds = clientEntity.InboundIds
// Update client using ClientService (this handles Settings update automatically)
needRestart, err := clientService.UpdateClient(oldInbound.UserId, updatedEntity)
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
if len(clients[0].Email) > 0 {
if len(oldEmail) > 0 {
err = s.UpdateClientStat(tx, oldEmail, &clients[0])
if err != nil {
return false, err
}
err = s.UpdateClientIPs(tx, oldEmail, clients[0].Email)
if err != nil {
return false, err
}
} else {
s.AddClientStat(tx, data.Id, &clients[0])
}
} else {
err = s.DelClientStat(tx, oldEmail)
if err != nil {
return false, err
}
err = s.DelClientIPs(tx, oldEmail)
if err != nil {
return false, err
}
}
needRestart := false
if len(oldEmail) > 0 {
s.xrayApi.Init(p.GetAPIPort())
if oldClients[clientIndex].Enable {
err1 := s.xrayApi.RemoveUser(oldInbound.Tag, oldEmail)
if err1 == nil {
logger.Debug("Old client deleted by api:", oldEmail)
} else {
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client by api:", err1)
needRestart = true
}
}
}
if clients[0].Enable {
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
}
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"password": clients[0].Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client edited by api:", clients[0].Email)
} else {
logger.Debug("Error in adding client by api:", err1)
needRestart = true
}
}
s.xrayApi.Close()
} else {
logger.Debug("Client old email not found")
needRestart = true
}
return needRestart, tx.Save(oldInbound).Error
return needRestart, nil
}
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
@ -1147,14 +931,17 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
tx.Commit()
}
}()
err = s.addInboundTraffic(tx, inboundTraffics)
if err != nil {
return err, false
}
err = s.addClientTraffic(tx, clientTraffics)
// Client traffic is now handled by ClientService
// Inbound traffic will be synchronized as sum of all its clients' traffic
clientService := ClientService{}
clientsToDisable, _, err := clientService.AddClientTraffic(tx, clientTraffics, s)
if err != nil {
return err, false
}
// Note: We no longer update inbound traffic directly from Xray API
// Instead, inbound traffic is synchronized as sum of all its clients' traffic in AddClientTraffic
// This ensures consistency between inbound and client traffic
needRestart0, count, err := s.autoRenewClients(tx)
if err != nil {
@ -1163,19 +950,48 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
logger.Debugf("%v clients renewed", count)
}
needRestart1, count, err := s.disableInvalidClients(tx)
if err != nil {
logger.Warning("Error in disabling invalid clients:", err)
} else if count > 0 {
logger.Debugf("%v clients disabled", count)
}
needRestart2, count, err := s.disableInvalidInbounds(tx)
if err != nil {
logger.Warning("Error in disabling invalid inbounds:", err)
} else if count > 0 {
logger.Debugf("%v inbounds disabled", count)
// NOTE: disableInvalidClients is no longer needed - client disabling is handled by ClientService.AddClientTraffic
// which updates ClientEntity.Enable and client_traffics.enable, and then DisableClientsByEmail handles Xray API removal
// and Settings update. This ensures proper separation: clients are managed individually, not as part of inbound.
// NOTE: disableInvalidInbounds is disabled - inbound should NOT be blocked by traffic limits.
// Inbound is only a container for clients and should show statistics (sum of all clients' traffic).
// Traffic limits are managed at the client level only.
// If inbound needs to be disabled, it should be done manually via Enable flag, not automatically by traffic.
needRestart1 := false
needRestart2 := false
// Disable clients in new architecture (ClientEntity) after transaction commits
// This is done outside the transaction to avoid nested transactions
// The client_traffics.enable has already been updated in addClientTraffic
// Now we need to sync ClientEntity.Enable and remove from Xray API
// IMPORTANT: Only process if we have clients to disable AND transaction was successful
if len(clientsToDisable) > 0 && err == nil {
logger.Debugf("AddTraffic: %d clients need to be disabled: %v", len(clientsToDisable), clientsToDisable)
// Run in goroutine to avoid blocking traffic updates
go func() {
clientService := ClientService{}
needRestart3, err := clientService.DisableClientsByEmail(clientsToDisable, s)
if err != nil {
logger.Warning("Error in disabling clients in new architecture:", err)
} else if needRestart3 {
// Restart Xray if needed (e.g., if API removal failed)
xrayService := XrayService{
inboundService: *s,
settingService: SettingService{},
nodeService: NodeService{},
}
if err := xrayService.RestartXray(false); err != nil {
logger.Warningf("Failed to restart Xray after client removal: %v", err)
} else {
logger.Infof("Xray restarted after client removal")
}
}
}()
} else if len(clientsToDisable) > 0 {
logger.Debugf("AddTraffic: %d clients to disable but transaction failed, skipping", len(clientsToDisable))
}
return nil, (needRestart0 || needRestart1 || needRestart2)
}
@ -1202,66 +1018,8 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
return nil
}
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return nil
}
onlineClients := make([]string, 0)
emails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
emails = append(emails, traffic.Email)
}
dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
err = tx.Model(xray.ClientTraffic{}).Where("email IN (?)", emails).Find(&dbClientTraffics).Error
if err != nil {
return err
}
// Avoid empty slice error
if len(dbClientTraffics) == 0 {
return nil
}
dbClientTraffics, err = s.adjustTraffics(tx, dbClientTraffics)
if err != nil {
return err
}
for dbTraffic_index := range dbClientTraffics {
for traffic_index := range traffics {
if dbClientTraffics[dbTraffic_index].Email == traffics[traffic_index].Email {
dbClientTraffics[dbTraffic_index].Up += traffics[traffic_index].Up
dbClientTraffics[dbTraffic_index].Down += traffics[traffic_index].Down
dbClientTraffics[dbTraffic_index].AllTime += (traffics[traffic_index].Up + traffics[traffic_index].Down)
// Add user in onlineUsers array on traffic
if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 {
onlineClients = append(onlineClients, traffics[traffic_index].Email)
dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
}
break
}
}
}
// Set onlineUsers
if p != nil {
p.SetOnlineClients(onlineClients)
}
err = tx.Save(dbClientTraffics).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
}
return nil
}
// addClientTraffic is removed - now using ClientService.AddClientTraffic
// Traffic is managed through ClientEntity, not client_traffics table
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
inboundIds := make([]int, 0, len(dbClientTraffics))
@ -1520,43 +1278,14 @@ 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) UpdateClientStat(tx *gorm.DB, email string, client *model.Client) error {
result := tx.Model(xray.ClientTraffic{}).
Where("email = ?", email).
Updates(map[string]any{
"enable": client.Enable,
"email": client.Email,
"total": client.TotalGB,
"expiry_time": client.ExpiryTime,
"reset": client.Reset,
})
err := result.Error
return err
}
// AddClientStat, UpdateClientStat, DelClientStat are removed
// Clients are now managed through ClientEntity - traffic is stored directly in ClientEntity table
// These methods worked with deprecated client_traffics table
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
return tx.Model(model.InboundClientIps{}).Where("client_email = ?", oldEmail).Update("client_email", newEmail).Error
}
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
return tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error
}
func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
}
@ -2453,20 +2182,8 @@ func (s *InboundService) MigrationRequirements() {
inbounds[inbound_index].Settings = string(modifiedSettings)
}
// Add client traffic row for all clients which has email
modelClients, err := s.GetClients(inbounds[inbound_index])
if err != nil {
return
}
for _, modelClient := range modelClients {
if len(modelClient.Email) > 0 {
var count int64
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
if count == 0 {
s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
}
}
}
// Note: Client traffic is now stored in ClientEntity table
// No need to create client_traffics records - they are deprecated
}
tx.Save(inbounds)
@ -2581,94 +2298,49 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
return validEmails, extraEmails, nil
}
func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) {
// Get inbound to get userId
oldInbound, err := s.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
return false, err
}
var settings map[string]any
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
// Get all clients for this inbound (from ClientEntity)
oldClients, err := s.GetClients(oldInbound)
if err != nil {
return false, err
}
interfaceClients, ok := settings["clients"].([]any)
if !ok {
return false, common.NewError("invalid clients format in inbound settings")
}
var newClients []any
needApiDel := false
// Check if client exists
found := false
for _, client := range interfaceClients {
c, ok := client.(map[string]any)
if !ok {
continue
}
if cEmail, ok := c["email"].(string); ok && cEmail == email {
// matched client, drop it
for _, client := range oldClients {
if strings.ToLower(client.Email) == strings.ToLower(email) {
found = true
needApiDel, _ = c["enable"].(bool)
} else {
newClients = append(newClients, client)
break
}
}
if !found {
return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
}
if len(newClients) == 0 {
// Check if this is the only client in the inbound
if len(oldClients) <= 1 {
return false, common.NewError("no client remained in Inbound")
}
settings["clients"] = newClients
newSettings, err := json.MarshalIndent(settings, "", " ")
// Find ClientEntity by email
clientService := ClientService{}
clientEntity, err := clientService.GetClientByEmail(oldInbound.UserId, email)
if err != nil {
return false, common.NewError("ClientEntity not found")
}
// Delete client using ClientService (this handles Settings update automatically)
needRestart, err := clientService.DeleteClient(oldInbound.UserId, clientEntity.Id)
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
db := database.GetDB()
// remove IP bindings
if err := s.DelClientIPs(db, email); err != nil {
logger.Error("Error in delete client IPs")
return false, err
}
needRestart := false
// remove stats too
if len(email) > 0 {
traffic, err := s.GetClientTrafficByEmail(email)
if err != nil {
return false, err
}
if traffic != nil {
if err := s.DelClientStat(db, email); err != nil {
logger.Error("Delete stats Data Error")
return false, err
}
}
if needApiDel {
s.xrayApi.Init(p.GetAPIPort())
if err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email); err1 == nil {
logger.Debug("Client deleted by api:", email)
needRestart = false
} else {
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client by api:", err1)
needRestart = true
}
}
s.xrayApi.Close()
}
}
return needRestart, db.Save(oldInbound).Error
return needRestart, nil
}

View file

@ -703,6 +703,7 @@
[pages.clients.toasts]
"clientCreateSuccess" = "Client created successfully"
"clientUpdateSuccess" = "Client updated successfully"
"clientDeleteSuccess" = "Client deleted successfully"
[pages.hosts]
"title" = "Hosts Management"

View file

@ -703,6 +703,7 @@
[pages.clients.toasts]
"clientCreateSuccess" = "Клиент успешно создан"
"clientUpdateSuccess" = "Клиент успешно обновлен"
"clientDeleteSuccess" = "Клиент успешно удален"
[pages.hosts]
"title" = "Управление хостами"

View file

@ -314,8 +314,8 @@ func (s *Server) startTask() {
go func() {
time.Sleep(time.Second * 5)
// Statistics every 10 seconds, start the delay for 5 seconds for the first time, and staggered with the time to restart xray
s.cron.AddJob("@every 10s", job.NewXrayTrafficJob())
// Statistics every 3 seconds for faster traffic limit enforcement, start the delay for 5 seconds for the first time, and staggered with the time to restart xray
s.cron.AddJob("@every 3s", job.NewXrayTrafficJob())
}()
// check client ips from log file every 10 sec

View file

@ -240,6 +240,10 @@ func (x *XrayAPI) GetTraffic(reset bool) ([]*Traffic, []*ClientTraffic, error) {
}
// processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
// Note: In Xray API terminology:
// - "downlink" = traffic from client to server → maps to Traffic.Down (from server perspective)
// - "uplink" = traffic from server to client → maps to Traffic.Up (from server perspective)
// For inbounds: downlink is what clients send (server receives), uplink is what server sends (clients receive)
func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
isInbound := matches[1] == "inbound"
tag := matches[2]
@ -259,14 +263,19 @@ func processTraffic(matches []string, value int64, trafficMap map[string]*Traffi
trafficMap[tag] = traffic
}
// Direct mapping: downlink → Down, uplink → Up
if isDown {
traffic.Down = value
traffic.Down = value // downlink = traffic from clients to server
} else {
traffic.Up = value
traffic.Up = value // uplink = traffic from server to clients
}
}
// processClientTraffic updates clientTrafficMap with upload/download values for a client email.
// Note: In Xray API terminology:
// - "downlink" = traffic from client to server → maps to ClientTraffic.Down
// - "uplink" = traffic from server to client → maps to ClientTraffic.Up
// This matches the server perspective and is consistent with processTraffic for inbounds.
func processClientTraffic(matches []string, value int64, clientTrafficMap map[string]*ClientTraffic) {
email := matches[1]
isDown := matches[2] == "downlink"
@ -277,10 +286,11 @@ func processClientTraffic(matches []string, value int64, clientTrafficMap map[st
clientTrafficMap[email] = traffic
}
// Direct mapping: downlink → Down, uplink → Up (consistent with processTraffic)
if isDown {
traffic.Down = value
traffic.Down = value // downlink = traffic from client to server
} else {
traffic.Up = value
traffic.Up = value // uplink = traffic from server to client
}
}