mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
refactor UI and clients logic
This commit is contained in:
parent
fa7759280b
commit
5ed25ee08e
19 changed files with 1810 additions and 728 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
320
web/service/client_traffic.go
Normal file
320
web/service/client_traffic.go
Normal 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
|
||||
}
|
||||
|
|
@ -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(¬Depleted).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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -703,6 +703,7 @@
|
|||
[pages.clients.toasts]
|
||||
"clientCreateSuccess" = "Client created successfully"
|
||||
"clientUpdateSuccess" = "Client updated successfully"
|
||||
"clientDeleteSuccess" = "Client deleted successfully"
|
||||
|
||||
[pages.hosts]
|
||||
"title" = "Hosts Management"
|
||||
|
|
|
|||
|
|
@ -703,6 +703,7 @@
|
|||
[pages.clients.toasts]
|
||||
"clientCreateSuccess" = "Клиент успешно создан"
|
||||
"clientUpdateSuccess" = "Клиент успешно обновлен"
|
||||
"clientDeleteSuccess" = "Клиент успешно удален"
|
||||
|
||||
[pages.hosts]
|
||||
"title" = "Управление хостами"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
xray/api.go
18
xray/api.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue