mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
1282 lines
No EOL
40 KiB
Go
1282 lines
No EOL
40 KiB
Go
// Package service provides Client management service.
|
|
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/mhsanaei/3x-ui/v2/database"
|
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
|
"github.com/mhsanaei/3x-ui/v2/util/random"
|
|
"github.com/mhsanaei/3x-ui/v2/web/cache"
|
|
"github.com/mhsanaei/3x-ui/v2/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ClientService provides business logic for managing clients.
|
|
type ClientService struct{}
|
|
|
|
// GetClients retrieves all clients for a specific user.
|
|
// Also loads traffic statistics and last online time for each client.
|
|
// Results are cached in Redis for 30 seconds.
|
|
func (s *ClientService) GetClients(userId int) ([]*model.ClientEntity, error) {
|
|
key := fmt.Sprintf("%s%d", cache.KeyClientsPrefix, userId)
|
|
var clients []*model.ClientEntity
|
|
|
|
err := cache.GetOrSet(key, &clients, cache.TTLClients, func() (interface{}, error) {
|
|
// Cache miss - fetch from database
|
|
db := database.GetDB()
|
|
var result []*model.ClientEntity
|
|
err := db.Where("user_id = ?", userId).Find(&result).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load inbound assignments, traffic statistics, and HWIDs for each client
|
|
for _, client := range result {
|
|
// Load inbound assignments
|
|
inboundIds, err := s.GetInboundIdsForClient(client.Id)
|
|
if err == nil {
|
|
client.InboundIds = inboundIds
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load HWIDs for this client
|
|
hwidService := ClientHWIDService{}
|
|
hwids, err := hwidService.GetHWIDsForClient(client.Id)
|
|
if err == nil {
|
|
client.HWIDs = hwids
|
|
} else {
|
|
logger.Warningf("Failed to load HWIDs for client %d: %v", client.Id, err)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
})
|
|
|
|
return clients, err
|
|
}
|
|
|
|
// 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
|
|
err := db.First(&client, id).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load inbound assignments
|
|
inboundIds, err := s.GetInboundIdsForClient(client.Id)
|
|
if err == nil {
|
|
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)
|
|
if err == nil {
|
|
client.HWIDs = hwids
|
|
}
|
|
|
|
return &client, nil
|
|
}
|
|
|
|
// GetClientByEmail retrieves a client by email for a specific user.
|
|
func (s *ClientService) GetClientByEmail(userId int, email string) (*model.ClientEntity, error) {
|
|
db := database.GetDB()
|
|
var client model.ClientEntity
|
|
err := db.Where("user_id = ? AND email = ?", userId, strings.ToLower(email)).First(&client).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load inbound assignments
|
|
inboundIds, err := s.GetInboundIdsForClient(client.Id)
|
|
if err == nil {
|
|
client.InboundIds = inboundIds
|
|
}
|
|
|
|
return &client, nil
|
|
}
|
|
|
|
// GetInboundIdsForClient retrieves all inbound IDs assigned to a client.
|
|
func (s *ClientService) GetInboundIdsForClient(clientId int) ([]int, error) {
|
|
db := database.GetDB()
|
|
var mappings []model.ClientInboundMapping
|
|
err := db.Where("client_id = ?", clientId).Find(&mappings).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inboundIds := make([]int, len(mappings))
|
|
for i, mapping := range mappings {
|
|
inboundIds[i] = mapping.InboundId
|
|
}
|
|
|
|
return inboundIds, nil
|
|
}
|
|
|
|
// AddClient creates a new client.
|
|
// Returns whether Xray needs restart and any error.
|
|
func (s *ClientService) AddClient(userId int, client *model.ClientEntity) (bool, error) {
|
|
// Validate email uniqueness for this user
|
|
existing, err := s.GetClientByEmail(userId, client.Email)
|
|
if err == nil && existing != nil {
|
|
return false, common.NewError("Client with email already exists: ", client.Email)
|
|
}
|
|
|
|
// Generate UUID if not provided and needed
|
|
if client.UUID == "" {
|
|
newUUID, err := uuid.NewRandom()
|
|
if err != nil {
|
|
return false, common.NewError("Failed to generate UUID: ", err.Error())
|
|
}
|
|
client.UUID = newUUID.String()
|
|
}
|
|
|
|
// Generate SubID if not provided
|
|
if client.SubID == "" {
|
|
client.SubID = random.Seq(16)
|
|
}
|
|
|
|
// Normalize email to lowercase
|
|
client.Email = strings.ToLower(client.Email)
|
|
client.UserId = userId
|
|
|
|
// Set timestamps
|
|
now := time.Now().Unix()
|
|
if client.CreatedAt == 0 {
|
|
client.CreatedAt = now
|
|
}
|
|
client.UpdatedAt = now
|
|
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
} else {
|
|
tx.Commit()
|
|
}
|
|
}()
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
err = s.AssignClientToInbounds(tx, client.Id, client.InboundIds)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// Commit client transaction first to avoid nested transactions
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Invalidate cache for this user's clients
|
|
cache.InvalidateClients(userId)
|
|
|
|
// Now update Settings for all assigned inbounds
|
|
// This is done AFTER committing the client transaction to avoid nested transactions and database locks
|
|
needRestart := false
|
|
if len(client.InboundIds) > 0 {
|
|
inboundService := InboundService{}
|
|
for _, inboundId := range client.InboundIds {
|
|
inbound, err := inboundService.GetInbound(inboundId)
|
|
if err != nil {
|
|
logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err)
|
|
continue
|
|
}
|
|
|
|
// Get all clients for this inbound (from ClientEntity)
|
|
clientEntities, err := s.GetClientsForInbound(inboundId)
|
|
if err != nil {
|
|
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
|
|
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 (this will open its own transaction)
|
|
// Use retry logic to handle database lock errors
|
|
inbound.Settings = newSettings
|
|
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
|
|
if err != nil {
|
|
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
|
|
// Continue with other inbounds
|
|
} else if inboundNeedRestart {
|
|
needRestart = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return needRestart, nil
|
|
}
|
|
|
|
// UpdateClient updates an existing client.
|
|
// Returns whether Xray needs restart and any error.
|
|
func (s *ClientService) UpdateClient(userId int, client *model.ClientEntity) (bool, error) {
|
|
// Check if client exists and belongs to user
|
|
existing, err := s.GetClient(client.Id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if existing.UserId != userId {
|
|
return false, common.NewError("Client not found or access denied")
|
|
}
|
|
|
|
// Check email uniqueness if email changed
|
|
if client.Email != "" && strings.ToLower(client.Email) != strings.ToLower(existing.Email) {
|
|
existingByEmail, err := s.GetClientByEmail(userId, client.Email)
|
|
if err == nil && existingByEmail != nil && existingByEmail.Id != client.Id {
|
|
return false, common.NewError("Client with email already exists: ", client.Email)
|
|
}
|
|
}
|
|
|
|
// Normalize email to lowercase if provided
|
|
if client.Email != "" {
|
|
client.Email = strings.ToLower(client.Email)
|
|
}
|
|
|
|
// Update timestamp
|
|
client.UpdatedAt = time.Now().Unix()
|
|
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
// Track if transaction was committed to avoid double rollback
|
|
committed := false
|
|
defer func() {
|
|
// Only rollback if there was an error and transaction wasn't committed
|
|
if err != nil && !committed {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
// Update only provided fields
|
|
updates := make(map[string]interface{})
|
|
if client.Email != "" {
|
|
updates["email"] = client.Email
|
|
}
|
|
if client.UUID != "" {
|
|
updates["uuid"] = client.UUID
|
|
}
|
|
if client.Security != "" {
|
|
updates["security"] = client.Security
|
|
}
|
|
if client.Password != "" {
|
|
updates["password"] = client.Password
|
|
}
|
|
if client.Flow != "" {
|
|
updates["flow"] = client.Flow
|
|
}
|
|
if client.LimitIP > 0 {
|
|
updates["limit_ip"] = client.LimitIP
|
|
}
|
|
// 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
|
|
}
|
|
if client.SubID != "" {
|
|
updates["sub_id"] = client.SubID
|
|
}
|
|
if client.Comment != "" {
|
|
updates["comment"] = client.Comment
|
|
}
|
|
if client.Reset > 0 {
|
|
updates["reset"] = client.Reset
|
|
}
|
|
// Update HWID settings - GORM converts field names to snake_case automatically
|
|
// HWIDEnabled -> hwid_enabled, MaxHWID -> max_hwid
|
|
// But we need to check if columns exist first, or use direct field assignment
|
|
updates["hwid_enabled"] = client.HWIDEnabled
|
|
updates["max_hwid"] = client.MaxHWID
|
|
updates["updated_at"] = client.UpdatedAt
|
|
|
|
// First try to update with all fields including HWID
|
|
err = tx.Model(&model.ClientEntity{}).Where("id = ? AND user_id = ?", client.Id, userId).Updates(updates).Error
|
|
if err != nil {
|
|
// If HWID columns don't exist, remove them and try again
|
|
if strings.Contains(err.Error(), "no such column: hwid_enabled") || strings.Contains(err.Error(), "no such column: max_hwid") {
|
|
delete(updates, "hwid_enabled")
|
|
delete(updates, "max_hwid")
|
|
err = tx.Model(&model.ClientEntity{}).Where("id = ? AND user_id = ?", client.Id, userId).Updates(updates).Error
|
|
}
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Get current inbound assignments to determine which inbounds need updating
|
|
var currentMappings []model.ClientInboundMapping
|
|
tx.Where("client_id = ?", client.Id).Find(¤tMappings)
|
|
oldInboundIds := make(map[int]bool)
|
|
for _, mapping := range currentMappings {
|
|
oldInboundIds[mapping.InboundId] = true
|
|
}
|
|
|
|
// Track all affected inbounds (old + new) for settings update
|
|
affectedInboundIds := make(map[int]bool)
|
|
for inboundId := range oldInboundIds {
|
|
affectedInboundIds[inboundId] = true
|
|
}
|
|
|
|
// Update inbound assignments if provided
|
|
// Note: InboundIds is a slice, so we need to check if it was explicitly set
|
|
// We'll always update if InboundIds is not nil (even if empty array means remove all)
|
|
if client.InboundIds != nil {
|
|
// Remove existing assignments
|
|
err = tx.Where("client_id = ?", client.Id).Delete(&model.ClientInboundMapping{}).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Add new assignments (if any)
|
|
if len(client.InboundIds) > 0 {
|
|
err = s.AssignClientToInbounds(tx, client.Id, client.InboundIds)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// Track new inbound IDs for settings update
|
|
for _, inboundId := range client.InboundIds {
|
|
affectedInboundIds[inboundId] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Invalidate cache for this user's clients
|
|
cache.InvalidateClients(userId)
|
|
|
|
// Now update Settings for all affected inbounds (old + new)
|
|
// This is needed even if InboundIds wasn't changed, because client data (UUID, password, etc.) might have changed
|
|
// 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 {
|
|
logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err)
|
|
continue
|
|
}
|
|
|
|
// Get all clients for this inbound (from ClientEntity)
|
|
clientEntities, err := s.GetClientsForInbound(inboundId)
|
|
if err != nil {
|
|
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
|
|
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 (this will open its own transaction)
|
|
// Use retry logic to handle database lock errors
|
|
inbound.Settings = newSettings
|
|
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
|
|
if err != nil {
|
|
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
|
|
// Continue with other inbounds
|
|
} else if inboundNeedRestart {
|
|
needRestart = true
|
|
}
|
|
}
|
|
|
|
return needRestart, nil
|
|
}
|
|
|
|
// DeleteClient deletes a client by ID.
|
|
// Returns whether Xray needs restart and any error.
|
|
func (s *ClientService) DeleteClient(userId int, id int) (bool, error) {
|
|
// Check if client exists and belongs to user
|
|
existing, err := s.GetClient(id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if existing.UserId != userId {
|
|
return false, common.NewError("Client not found or access denied")
|
|
}
|
|
|
|
// Get inbound assignments before deleting
|
|
var mappings []model.ClientInboundMapping
|
|
db := database.GetDB()
|
|
err = db.Where("client_id = ?", id).Find(&mappings).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
affectedInboundIds := make(map[int]bool)
|
|
for _, mapping := range mappings {
|
|
affectedInboundIds[mapping.InboundId] = true
|
|
}
|
|
|
|
needRestart := false
|
|
|
|
tx := db.Begin()
|
|
// Track if transaction was committed to avoid double rollback
|
|
committed := false
|
|
defer func() {
|
|
// Only rollback if there was an error and transaction wasn't committed
|
|
if err != nil && !committed {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
// Delete inbound mappings
|
|
err = tx.Where("client_id = ?", id).Delete(&model.ClientInboundMapping{}).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Delete client
|
|
err = tx.Where("id = ? AND user_id = ?", id, userId).Delete(&model.ClientEntity{}).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Commit deletion transaction first to avoid nested transactions
|
|
err = tx.Commit().Error
|
|
committed = true
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Update Settings for affected inbounds (after deletion)
|
|
// We do this AFTER committing the deletion transaction to avoid nested transactions and database locks
|
|
inboundService := InboundService{}
|
|
for inboundId := range affectedInboundIds {
|
|
inbound, err := inboundService.GetInbound(inboundId)
|
|
if err != nil {
|
|
logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err)
|
|
continue
|
|
}
|
|
|
|
// Get all remaining clients for this inbound (from ClientEntity)
|
|
clientEntities, err := s.GetClientsForInbound(inboundId)
|
|
if err != nil {
|
|
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
|
|
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 (this will open its own transaction)
|
|
// Use retry logic to handle database lock errors
|
|
inbound.Settings = newSettings
|
|
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
|
|
if err != nil {
|
|
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
|
|
// Continue with other inbounds
|
|
} else if inboundNeedRestart {
|
|
needRestart = true
|
|
}
|
|
}
|
|
|
|
return needRestart, nil
|
|
}
|
|
|
|
// AssignClientToInbounds assigns a client to multiple inbounds.
|
|
func (s *ClientService) AssignClientToInbounds(tx *gorm.DB, clientId int, inboundIds []int) error {
|
|
for _, inboundId := range inboundIds {
|
|
mapping := &model.ClientInboundMapping{
|
|
ClientId: clientId,
|
|
InboundId: inboundId,
|
|
}
|
|
err := tx.Create(mapping).Error
|
|
if err != nil {
|
|
logger.Warningf("Failed to assign client %d to inbound %d: %v", clientId, inboundId, err)
|
|
// Continue with other assignments
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetClientsForInbound retrieves all clients assigned to an inbound.
|
|
func (s *ClientService) GetClientsForInbound(inboundId int) ([]*model.ClientEntity, error) {
|
|
db := database.GetDB()
|
|
var mappings []model.ClientInboundMapping
|
|
err := db.Where("inbound_id = ?", inboundId).Find(&mappings).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(mappings) == 0 {
|
|
return []*model.ClientEntity{}, nil
|
|
}
|
|
|
|
clientIds := make([]int, len(mappings))
|
|
for i, mapping := range mappings {
|
|
clientIds[i] = mapping.ClientId
|
|
}
|
|
|
|
var clients []*model.ClientEntity
|
|
err = db.Where("id IN ?", clientIds).Find(&clients).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return clients, nil
|
|
}
|
|
|
|
// ConvertClientEntityToClient converts ClientEntity to legacy Client struct for backward compatibility.
|
|
func (s *ClientService) ConvertClientEntityToClient(entity *model.ClientEntity) model.Client {
|
|
return model.Client{
|
|
ID: entity.UUID,
|
|
Security: entity.Security,
|
|
Password: entity.Password,
|
|
Flow: entity.Flow,
|
|
Email: entity.Email,
|
|
LimitIP: entity.LimitIP,
|
|
TotalGB: int64(entity.TotalGB), // Convert float64 to int64 for legacy compatibility (rounds down)
|
|
ExpiryTime: entity.ExpiryTime,
|
|
Enable: entity.Enable,
|
|
TgID: entity.TgID,
|
|
SubID: entity.SubID,
|
|
Comment: entity.Comment,
|
|
Reset: entity.Reset,
|
|
CreatedAt: entity.CreatedAt,
|
|
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
|
|
} |