mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
feat: implement sub-quota propagation and automatic re-enablement for clients sharing the same SubID
This commit is contained in:
parent
2f3243a2cf
commit
1c299578aa
1 changed files with 328 additions and 4 deletions
|
|
@ -1632,6 +1632,45 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||||
logger.Debug("Client old email not found")
|
logger.Debug("Client old email not found")
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
|
// Propagate subTotalGB changes to all siblings sharing the same SubID.
|
||||||
|
newClient := clients[0]
|
||||||
|
if strings.TrimSpace(newClient.SubID) != "" && newClient.SubTotalGB > 0 {
|
||||||
|
// First: update siblings within THIS inbound's settings.
|
||||||
|
var localSettings map[string]any
|
||||||
|
if err2 := json.Unmarshal([]byte(oldInbound.Settings), &localSettings); err2 == nil {
|
||||||
|
if localClients, ok := localSettings["clients"].([]any); ok {
|
||||||
|
localChanged := false
|
||||||
|
for i, ic := range localClients {
|
||||||
|
cm, ok := ic.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cSubId, _ := cm["subId"].(string)
|
||||||
|
if cSubId != newClient.SubID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldVal, _ := cm["subTotalGB"].(float64)
|
||||||
|
if int64(oldVal) == newClient.SubTotalGB {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cm["subTotalGB"] = newClient.SubTotalGB
|
||||||
|
localClients[i] = cm
|
||||||
|
localChanged = true
|
||||||
|
}
|
||||||
|
if localChanged {
|
||||||
|
localSettings["clients"] = localClients
|
||||||
|
if bs, err3 := json.MarshalIndent(localSettings, "", " "); err3 == nil {
|
||||||
|
oldInbound.Settings = string(bs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Then: propagate to OTHER inbounds.
|
||||||
|
if err := s.syncSubTotalGB(tx, newClient.SubID, newClient.SubTotalGB); err != nil {
|
||||||
|
logger.Warning("syncSubTotalGB from UpdateInboundClient:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return needRestart, tx.Save(oldInbound).Error
|
return needRestart, tx.Save(oldInbound).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1959,13 +1998,20 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||||
disabledClientsCount += count
|
disabledClientsCount += count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
needRestart4, count, err := s.reEnableSubQuotaClients(tx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Error in re-enabling sub-quota clients:", err)
|
||||||
|
} else if count > 0 {
|
||||||
|
logger.Debugf("%v clients re-enabled by sub quota", count)
|
||||||
|
}
|
||||||
|
|
||||||
needRestart2, count, err := s.disableInvalidInbounds(tx)
|
needRestart2, count, err := s.disableInvalidInbounds(tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("Error in disabling invalid inbounds:", err)
|
logger.Warning("Error in disabling invalid inbounds:", err)
|
||||||
} else if count > 0 {
|
} else if count > 0 {
|
||||||
logger.Debugf("%v inbounds disabled", count)
|
logger.Debugf("%v inbounds disabled", count)
|
||||||
}
|
}
|
||||||
return needRestart0 || needRestart1 || needRestart2 || needRestart3, disabledClientsCount > 0, nil
|
return needRestart0 || needRestart1 || needRestart2 || needRestart3 || needRestart4, disabledClientsCount > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||||
|
|
@ -2489,7 +2535,7 @@ func (s *InboundService) GetSubTrafficInfo(subId string) (*SubTrafficInfo, error
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
emails = append(emails, email)
|
emails = append(emails, email)
|
||||||
if subTotal == 0 && r.SubTotalGB > 0 {
|
if r.SubTotalGB > subTotal {
|
||||||
subTotal = r.SubTotalGB
|
subTotal = r.SubTotalGB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2546,7 +2592,7 @@ func (s *InboundService) disableSubQuotaClients(tx *gorm.DB) (bool, int64, error
|
||||||
return false, 0, err
|
return false, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Group by subId: collect emails and find the first non-zero subTotalGB.
|
// 2. Group by subId: collect emails and use MAX subTotalGB across siblings.
|
||||||
type subGroup struct {
|
type subGroup struct {
|
||||||
emails map[string]struct{}
|
emails map[string]struct{}
|
||||||
subTotalGB int64
|
subTotalGB int64
|
||||||
|
|
@ -2568,7 +2614,7 @@ func (s *InboundService) disableSubQuotaClients(tx *gorm.DB) (bool, int64, error
|
||||||
groups[subId] = g
|
groups[subId] = g
|
||||||
}
|
}
|
||||||
g.emails[strings.ToLower(email)] = struct{}{}
|
g.emails[strings.ToLower(email)] = struct{}{}
|
||||||
if g.subTotalGB == 0 && r.SubTotalGB > 0 {
|
if r.SubTotalGB > g.subTotalGB {
|
||||||
g.subTotalGB = r.SubTotalGB
|
g.subTotalGB = r.SubTotalGB
|
||||||
}
|
}
|
||||||
g.members = append(g.members, struct{ InboundId int; Tag, Email string }{
|
g.members = append(g.members, struct{ InboundId int; Tag, Email string }{
|
||||||
|
|
@ -2722,6 +2768,284 @@ func (s *InboundService) disableSubQuotaClients(tx *gorm.DB) (bool, int64, error
|
||||||
return needRestart, totalDisabled, nil
|
return needRestart, totalDisabled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncSubTotalGB propagates a changed subTotalGB value to ALL clients sharing
|
||||||
|
// the same SubID across ALL inbounds. This is called when the admin explicitly
|
||||||
|
// sets or changes the quota on any single client.
|
||||||
|
func (s *InboundService) syncSubTotalGB(tx *gorm.DB, subId string, newValue int64) error {
|
||||||
|
if strings.TrimSpace(subId) == "" || newValue < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all inbounds that have clients with this subId.
|
||||||
|
var inbounds []*model.Inbound
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT DISTINCT inbounds.*
|
||||||
|
FROM inbounds,
|
||||||
|
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||||
|
WHERE REPLACE(JSON_EXTRACT(client.value, '$.subId'), '"', '') = ?
|
||||||
|
`, subId).Scan(&inbounds).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix() * 1000
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
settings := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clientsRaw, ok := settings["clients"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i, ic := range clientsRaw {
|
||||||
|
cm, ok := ic.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cSubId, _ := cm["subId"].(string)
|
||||||
|
if cSubId != subId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldVal, _ := cm["subTotalGB"].(float64)
|
||||||
|
if int64(oldVal) == newValue {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cm["subTotalGB"] = newValue
|
||||||
|
cm["updated_at"] = now
|
||||||
|
clientsRaw[i] = cm
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings["clients"] = clientsRaw
|
||||||
|
bs, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inbound.Settings = string(bs)
|
||||||
|
if err := tx.Save(inbound).Error; err != nil {
|
||||||
|
logger.Warning("syncSubTotalGB save inbound:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reEnableSubQuotaClients checks all SubID groups for clients that were
|
||||||
|
// disabled by sub-quota enforcement but whose aggregate traffic is now
|
||||||
|
// below the (possibly increased) quota. Re-enables them in both
|
||||||
|
// client_traffics and inbound settings JSON.
|
||||||
|
func (s *InboundService) reEnableSubQuotaClients(tx *gorm.DB) (bool, int64, error) {
|
||||||
|
// 1. Same scan as disableSubQuotaClients.
|
||||||
|
var allRows []struct {
|
||||||
|
InboundId int
|
||||||
|
Tag string
|
||||||
|
Email string
|
||||||
|
SubID string `gorm:"column:sub_id"`
|
||||||
|
SubTotalGB int64 `gorm:"column:sub_total_gb"`
|
||||||
|
Enable bool `gorm:"column:enable"`
|
||||||
|
}
|
||||||
|
err := tx.Raw(`
|
||||||
|
SELECT inbounds.id AS inbound_id,
|
||||||
|
inbounds.tag AS tag,
|
||||||
|
REPLACE(JSON_EXTRACT(client.value, '$.email'), '"', '') AS email,
|
||||||
|
REPLACE(JSON_EXTRACT(client.value, '$.subId'), '"', '') AS sub_id,
|
||||||
|
COALESCE(JSON_EXTRACT(client.value, '$.subTotalGB'), 0) AS sub_total_gb,
|
||||||
|
JSON_EXTRACT(client.value, '$.enable') AS enable
|
||||||
|
FROM inbounds,
|
||||||
|
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||||
|
WHERE inbounds.node_id IS NULL
|
||||||
|
`).Scan(&allRows).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Group by subId.
|
||||||
|
type memberInfo struct {
|
||||||
|
InboundId int
|
||||||
|
Tag string
|
||||||
|
Email string
|
||||||
|
Enable bool
|
||||||
|
}
|
||||||
|
type subGroup struct {
|
||||||
|
emails map[string]struct{}
|
||||||
|
subTotalGB int64
|
||||||
|
members []memberInfo
|
||||||
|
hasDisabled bool
|
||||||
|
}
|
||||||
|
groups := make(map[string]*subGroup)
|
||||||
|
for _, r := range allRows {
|
||||||
|
subId := strings.TrimSpace(r.SubID)
|
||||||
|
if subId == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(r.Email)
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g, ok := groups[subId]
|
||||||
|
if !ok {
|
||||||
|
g = &subGroup{emails: make(map[string]struct{})}
|
||||||
|
groups[subId] = g
|
||||||
|
}
|
||||||
|
g.emails[strings.ToLower(email)] = struct{}{}
|
||||||
|
if r.SubTotalGB > g.subTotalGB {
|
||||||
|
g.subTotalGB = r.SubTotalGB
|
||||||
|
}
|
||||||
|
g.members = append(g.members, memberInfo{
|
||||||
|
InboundId: r.InboundId, Tag: r.Tag, Email: email, Enable: r.Enable,
|
||||||
|
})
|
||||||
|
if !r.Enable {
|
||||||
|
g.hasDisabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. For each group: if it has a quota, has disabled members, and
|
||||||
|
// aggregate traffic is under the quota → re-enable.
|
||||||
|
type enableTarget struct {
|
||||||
|
InboundId int
|
||||||
|
Tag string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
var toEnable []enableTarget
|
||||||
|
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.subTotalGB <= 0 || !g.hasDisabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
emails := make([]string, 0, len(g.emails))
|
||||||
|
for e := range g.emails {
|
||||||
|
emails = append(emails, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUsed int64
|
||||||
|
for _, batch := range chunkStrings(emails, sqliteMaxVars) {
|
||||||
|
var sum struct{ Total int64 }
|
||||||
|
if err := tx.Model(xray.ClientTraffic{}).
|
||||||
|
Select("COALESCE(SUM(up + down), 0) AS total").
|
||||||
|
Where("LOWER(email) IN ?", batch).
|
||||||
|
Scan(&sum).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalUsed += sum.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalUsed >= g.subTotalGB {
|
||||||
|
continue // still over quota
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under quota — re-enable disabled members.
|
||||||
|
for _, m := range g.members {
|
||||||
|
if !m.Enable {
|
||||||
|
toEnable = append(toEnable, enableTarget{
|
||||||
|
InboundId: m.InboundId, Tag: m.Tag, Email: m.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toEnable) == 0 {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Re-enable client_traffics rows (only those not individually over
|
||||||
|
// their own totalGB and not expired — leave those disabled).
|
||||||
|
enableEmails := make([]string, 0, len(toEnable))
|
||||||
|
for _, t := range toEnable {
|
||||||
|
enableEmails = append(enableEmails, t.Email)
|
||||||
|
}
|
||||||
|
uniqEnable := uniqueNonEmptyStrings(enableEmails)
|
||||||
|
var totalEnabled int64
|
||||||
|
for _, batch := range chunkStrings(uniqEnable, sqliteMaxVars) {
|
||||||
|
result := tx.Model(xray.ClientTraffic{}).
|
||||||
|
Where("LOWER(email) IN ? AND enable = ?", batch, false).
|
||||||
|
// Only re-enable if the client isn't individually over its own totalGB
|
||||||
|
// and hasn't expired.
|
||||||
|
Where("(total = 0 OR up + down < total)").
|
||||||
|
Where("(expiry_time = 0 OR expiry_time < 0 OR expiry_time > ?)", time.Now().UnixMilli()).
|
||||||
|
Update("enable", true)
|
||||||
|
if result.Error != nil {
|
||||||
|
logger.Warning("reEnableSubQuotaClients update client_traffics:", result.Error)
|
||||||
|
}
|
||||||
|
totalEnabled += result.RowsAffected
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update inbound settings JSON to set enable=true.
|
||||||
|
inboundEmailMap := make(map[int]map[string]struct{})
|
||||||
|
for _, t := range toEnable {
|
||||||
|
if inboundEmailMap[t.InboundId] == nil {
|
||||||
|
inboundEmailMap[t.InboundId] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
inboundEmailMap[t.InboundId][strings.ToLower(t.Email)] = struct{}{}
|
||||||
|
}
|
||||||
|
inboundIds := make([]int, 0, len(inboundEmailMap))
|
||||||
|
for id := range inboundEmailMap {
|
||||||
|
inboundIds = append(inboundIds, id)
|
||||||
|
}
|
||||||
|
var inbounds []*model.Inbound
|
||||||
|
if err = tx.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error; err != nil {
|
||||||
|
logger.Warning("reEnableSubQuotaClients fetch inbounds:", err)
|
||||||
|
return false, totalEnabled, nil
|
||||||
|
}
|
||||||
|
now := time.Now().Unix() * 1000
|
||||||
|
dirty := make([]*model.Inbound, 0)
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
settings := map[string]any{}
|
||||||
|
if jsonErr := json.Unmarshal([]byte(inbound.Settings), &settings); jsonErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clientsRaw, ok := settings["clients"].([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emailSet := inboundEmailMap[inbound.Id]
|
||||||
|
changed := false
|
||||||
|
for i := range clientsRaw {
|
||||||
|
c, ok := clientsRaw[i].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email, _ := c["email"].(string)
|
||||||
|
if _, shouldEnable := emailSet[strings.ToLower(email)]; !shouldEnable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Don't re-enable in settings if the client_traffics row wasn't
|
||||||
|
// re-enabled (due to individual quota or expiry).
|
||||||
|
var ct xray.ClientTraffic
|
||||||
|
if err := tx.Where("email = ?", email).First(&ct).Error; err == nil && !ct.Enable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c["enable"] = true
|
||||||
|
c["updated_at"] = now
|
||||||
|
clientsRaw[i] = c
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings["clients"] = clientsRaw
|
||||||
|
modifiedSettings, jsonErr := json.MarshalIndent(settings, "", " ")
|
||||||
|
if jsonErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inbound.Settings = string(modifiedSettings)
|
||||||
|
dirty = append(dirty, inbound)
|
||||||
|
}
|
||||||
|
if len(dirty) > 0 {
|
||||||
|
if err = tx.Save(dirty).Error; err != nil {
|
||||||
|
logger.Warning("reEnableSubQuotaClients update inbound settings:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalEnabled > 0 {
|
||||||
|
logger.Infof("Sub-quota: re-enabled %d clients after quota increase", totalEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalEnabled > 0, totalEnabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetInboundTags() (string, error) {
|
func (s *InboundService) GetInboundTags() (string, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inboundTags []string
|
var inboundTags []string
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue