add share quote between different clients from different inbounds with same subId

This commit is contained in:
Amir 2026-04-23 22:15:53 +00:00
parent a4b1b3d06d
commit 462cf857e8
20 changed files with 649 additions and 45 deletions

View file

@ -1 +1 @@
2.9.2
2.9.2-sharequota1

View file

@ -145,6 +145,7 @@ type Client struct {
SubID string `json:"subId" form:"subId"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
ShareQuota bool `json:"shareQuota" form:"shareQuota"` // Pool totalGB/expiryTime with siblings sharing subId
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}

View file

@ -45,6 +45,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
var traffic xray.ClientTraffic
var lastOnline int64
var clientTraffics []xray.ClientTraffic
var clientShared []bool
inbounds, err := s.getInboundsBySubId(subId)
if err != nil {
return nil, 0, traffic, err
@ -80,6 +81,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
result = append(result, link)
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
clientTraffics = append(clientTraffics, ct)
clientShared = append(clientShared, client.ShareQuota)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
}
@ -87,28 +89,90 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
}
}
// Prepare statistics
for index, clientTraffic := range clientTraffics {
if index == 0 {
traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total
if clientTraffic.ExpiryTime > 0 {
traffic.ExpiryTime = clientTraffic.ExpiryTime
// Detect whether any member of this subId opted into shared quota.
hasShared := false
for _, shared := range clientShared {
if shared {
hasShared = true
break
}
}
if !hasShared {
// No shareQuota in this subId: preserve the original aggregation (Total
// sums across members; ExpiryTime collapses to 0 when members disagree).
for index, clientTraffic := range clientTraffics {
if index == 0 {
traffic.Up = clientTraffic.Up
traffic.Down = clientTraffic.Down
traffic.Total = clientTraffic.Total
if clientTraffic.ExpiryTime > 0 {
traffic.ExpiryTime = clientTraffic.ExpiryTime
}
} else {
traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down
if traffic.Total == 0 || clientTraffic.Total == 0 {
traffic.Total = 0
} else {
traffic.Total += clientTraffic.Total
}
if clientTraffic.ExpiryTime != traffic.ExpiryTime {
traffic.ExpiryTime = 0
}
}
}
return result, lastOnline, traffic, nil
}
// Mixed-or-all-shared case: Up/Down sum across everyone (actual consumption).
// Total combines max-of-shared-totals + sum-of-unshared-totals so shared
// members aren't double-counted while unshared ones still add their own cap.
// ExpiryTime = earliest non-zero across all; 0 only if every member is unset.
var sharedMax int64
sharedMaxSeen := false
sharedUnlimited := false
var unsharedSum int64
unsharedUnlimited := false
for i, ct := range clientTraffics {
traffic.Up += ct.Up
traffic.Down += ct.Down
if clientShared[i] {
if ct.Total == 0 {
sharedUnlimited = true
} else if !sharedMaxSeen || ct.Total > sharedMax {
sharedMax = ct.Total
sharedMaxSeen = true
}
} else {
traffic.Up += clientTraffic.Up
traffic.Down += clientTraffic.Down
if traffic.Total == 0 || clientTraffic.Total == 0 {
traffic.Total = 0
if ct.Total == 0 {
unsharedUnlimited = true
} else {
traffic.Total += clientTraffic.Total
}
if clientTraffic.ExpiryTime != traffic.ExpiryTime {
traffic.ExpiryTime = 0
unsharedSum += ct.Total
}
}
}
if sharedUnlimited || unsharedUnlimited {
traffic.Total = 0
} else {
traffic.Total = 0
if sharedMaxSeen {
traffic.Total += sharedMax
}
traffic.Total += unsharedSum
}
traffic.ExpiryTime = 0
expirySet := false
for _, ct := range clientTraffics {
if ct.ExpiryTime == 0 {
continue
}
if !expirySet || ct.ExpiryTime < traffic.ExpiryTime {
traffic.ExpiryTime = ct.ExpiryTime
expirySet = true
}
}
return result, lastOnline, traffic, nil
}

View file

@ -2087,6 +2087,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
reset = 0,
created_at = undefined,
updated_at = undefined,
shareQuota = false,
) {
super();
this.email = email;
@ -2100,6 +2101,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
this.reset = reset;
this.created_at = created_at;
this.updated_at = updated_at;
this.shareQuota = !!shareQuota;
}
static commonArgsFromJson(json = {}) {
@ -2115,6 +2117,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
json.reset,
json.created_at,
json.updated_at,
json.shareQuota,
];
}
@ -2131,6 +2134,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
reset: this.reset,
created_at: this.created_at,
updated_at: this.updated_at,
shareQuota: !!this.shareQuota,
};
}
@ -2204,9 +2208,9 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
security = USERS_SECURITY.AUTO,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.id = id;
this.security = security;
}
@ -2309,9 +2313,9 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
flow = '',
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.id = id;
this.flow = flow;
}
@ -2407,9 +2411,9 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
Inbound.TrojanSettings.Trojan = class extends Inbound.ClientBase {
constructor(
password = RandomUtil.randomSeq(10),
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.password = password;
}
@ -2509,9 +2513,9 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
constructor(
method = '',
password = RandomUtil.randomShadowsocksPassword(),
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.method = method;
this.password = password;
}
@ -2559,9 +2563,9 @@ Inbound.HysteriaSettings = class extends Inbound.Settings {
Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
constructor(
auth = RandomUtil.randomSeq(10),
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.auth = auth;
}

View file

@ -108,6 +108,18 @@
</template>
<a-input v-model.trim="client.subId"></a-input>
</a-form-item>
<a-form-item v-if="client.email && app.subSettings?.enable && client.subId">
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.shareQuotaDesc" }}</span>
</template>
<span>{{ i18n "pages.inbounds.shareQuota" }}</span>
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-switch v-model="client.shareQuota"></a-switch>
</a-form-item>
<a-form-item v-if="client.email && app.tgBotEnable">
<template slot="label">
<a-tooltip>

View file

@ -1183,7 +1183,89 @@
this.ipLimitEnable = ipLimitEnable;
}
},
applyShareQuotaPooling(dbInbounds) {
// For every client that opted into shareQuota and has a subId, replace
// its clientStats up/down/allTime/total/expiryTime with values pooled
// across every sibling sharing the same subId. Existing rendering
// helpers (getUpStats, statsProgress, ...) then show the pooled totals
// without further changes.
//
// Note: enable is intentionally NOT pooled so an admin who disables a
// single sibling doesn't visually disable the others; depletion-driven
// disables are already applied per-row by the backend to every group
// member.
//
// The original per-row values are stashed under `_sharedQuotaRaw*` on
// first mutation so repeated calls on the same objects stay idempotent
// (no double-summing if setInbounds runs twice against the same array).
const readRaw = (stats, key) => {
const rawKey = '_sharedQuotaRaw_' + key;
return stats[rawKey] !== undefined ? stats[rawKey] : stats[key];
};
const stashRaw = (stats, key) => {
const rawKey = '_sharedQuotaRaw_' + key;
if (stats[rawKey] === undefined) stats[rawKey] = stats[key];
};
const groups = {};
for (const dbInbound of dbInbounds) {
if (!dbInbound.settings || dbInbound.settings.length === 0) continue;
let parsed;
try { parsed = JSON.parse(dbInbound.settings); } catch (_) { continue; }
const clients = parsed && parsed.clients;
if (!Array.isArray(clients)) continue;
for (const c of clients) {
if (!c || !c.shareQuota || !c.subId || !c.email) continue;
if (!groups[c.subId]) groups[c.subId] = { members: [] };
groups[c.subId].members.push({ dbInbound, email: c.email });
}
}
for (const subId in groups) {
const g = groups[subId];
if (g.members.length < 1) continue;
let up = 0, down = 0, allTime = 0;
let total = 0, totalUnlimited = false;
let expiryTime = 0, expiryNever = false, expirySet = false;
const rows = [];
for (const m of g.members) {
const stats = (m.dbInbound.clientStats || []).find(r => r.email === m.email);
if (!stats) continue;
rows.push(stats);
const rawUp = readRaw(stats, 'up');
const rawDown = readRaw(stats, 'down');
const rawAllTime = readRaw(stats, 'allTime');
const rawTotal = readRaw(stats, 'total');
const rawExpiry = readRaw(stats, 'expiryTime');
up += rawUp || 0;
down += rawDown || 0;
allTime += rawAllTime || 0;
if (rawTotal === 0) totalUnlimited = true;
else if (rawTotal > total) total = rawTotal;
if (rawExpiry === 0) expiryNever = true;
else {
if (!expirySet || rawExpiry < expiryTime) {
expiryTime = rawExpiry;
expirySet = true;
}
}
}
const pooledTotal = totalUnlimited ? 0 : total;
const pooledExpiry = expiryNever ? 0 : (expirySet ? expiryTime : 0);
for (const r of rows) {
stashRaw(r, 'up');
stashRaw(r, 'down');
stashRaw(r, 'allTime');
stashRaw(r, 'total');
stashRaw(r, 'expiryTime');
r.up = up;
r.down = down;
r.allTime = allTime;
r.total = pooledTotal;
r.expiryTime = pooledExpiry;
}
}
},
setInbounds(dbInbounds) {
this.applyShareQuotaPooling(dbInbounds);
this.inbounds.splice(0);
this.dbInbounds.splice(0);
this.clientCount.splice(0);

View file

@ -722,6 +722,49 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}
}()
// Coerce any incoming share-quota clients to the canonical values of an
// existing group (same subId, shareQuota=true), so a new arrival doesn't
// overwrite siblings' quota/expiry. Mutate both the typed slice and the JSON
// interface slice that gets persisted.
for i := range clients {
c := &clients[i]
if !c.ShareQuota || c.SubID == "" {
continue
}
var (
groupTotal int64
groupExpiry int64
exists bool
)
groupTotal, groupExpiry, exists, err = s.shareQuotaGroupValues(tx, c.SubID, c.Email)
if err != nil {
return false, err
}
if !exists {
continue
}
c.TotalGB = groupTotal
c.ExpiryTime = groupExpiry
for j := range interfaceClients {
cm, ok := interfaceClients[j].(map[string]any)
if !ok {
continue
}
if cm["email"] == c.Email {
cm["totalGB"] = groupTotal
cm["expiryTime"] = groupExpiry
interfaceClients[j] = cm
}
}
}
// Re-marshal settings in case share-quota coercion rewrote any client values.
oldSettings["clients"] = oldClients
newSettings, err = json.MarshalIndent(oldSettings, "", " ")
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
needRestart := false
s.xrayApi.Init(p.GetAPIPort())
for _, client := range clients {
@ -754,7 +797,20 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}
s.xrayApi.Close()
return needRestart, tx.Save(oldInbound).Error
if err = tx.Save(oldInbound).Error; err != nil {
return needRestart, err
}
// After the new client rows exist, sync quota/expiry across every member of
// this share-quota group so they all agree.
for _, client := range clients {
if client.ShareQuota && client.SubID != "" {
if err = s.propagateShareQuota(tx, client.SubID, client.TotalGB, client.ExpiryTime); err != nil {
return needRestart, err
}
}
}
return needRestart, nil
}
func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
@ -1115,13 +1171,22 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
return false, err
}
settingsClients := oldSettings["clients"].([]any)
// Preserve created_at and set updated_at for the replacing client
// Preserve created_at and set updated_at for the replacing client.
// Also preserve shareQuota if the incoming payload is missing the key
// (defensive: protects against clients running outdated JS that doesn't
// serialize the field).
var preservedCreated any
var preservedShareQuota any
var hadOldShareQuota bool
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 v, ok2 := oldMap["shareQuota"]; ok2 {
preservedShareQuota = v
hadOldShareQuota = true
}
}
}
if len(interfaceClients) > 0 {
@ -1131,6 +1196,26 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
}
newMap["created_at"] = preservedCreated
newMap["updated_at"] = time.Now().Unix() * 1000
if _, hasShare := newMap["shareQuota"]; !hasShare && hadOldShareQuota {
// Normalise to bool; tolerate "true"/"false"/"1"/"0" strings or
// numeric 0/1 in case a legacy record stored a non-boolean.
var asBool bool
switch v := preservedShareQuota.(type) {
case bool:
asBool = v
case string:
asBool = strings.EqualFold(v, "true") || v == "1"
case float64:
asBool = v != 0
case int:
asBool = v != 0
case int64:
asBool = v != 0
}
newMap["shareQuota"] = asBool
// Keep the typed clients[0] consistent so downstream propagation sees it.
clients[0].ShareQuota = asBool
}
interfaceClients[0] = newMap
}
}
@ -1219,7 +1304,17 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
logger.Debug("Client old email not found")
needRestart = true
}
return needRestart, tx.Save(oldInbound).Error
if err = tx.Save(oldInbound).Error; err != nil {
return needRestart, err
}
// If the saved client opted into share-quota, propagate its totalGB/expiryTime
// to every sibling with the same subId that also has shareQuota enabled.
if clients[0].ShareQuota && clients[0].SubID != "" {
if err = s.propagateShareQuota(tx, clients[0].SubID, clients[0].TotalGB, clients[0].ExpiryTime); err != nil {
return needRestart, err
}
}
return needRestart, nil
}
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
@ -1536,22 +1631,343 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
return needRestart, count, err
}
// shareQuotaMember describes a single client participating in a shared-quota group.
type shareQuotaMember struct {
Email string
InboundId int
Tag string
}
// shareQuotaGroup pools totalGB and expiryTime across clients sharing the same subId
// and having ShareQuota enabled. MaxTotal=0 means unlimited; MinExpiry=0 means never.
type shareQuotaGroup struct {
Members []shareQuotaMember
TotalUp int64
TotalDown int64
MaxTotal int64
MinExpiry int64
}
// computeShareQuotaGroups walks every inbound's settings JSON, collects clients
// with shareQuota==true && subId!="", groups them by subId, and joins with client_traffics
// to accumulate pooled usage. Keyed by subId. Disabled inbounds are included so
// a re-enable doesn't reset accounting.
func (s *InboundService) computeShareQuotaGroups(tx *gorm.DB) (map[string]*shareQuotaGroup, error) {
var inbounds []*model.Inbound
err := tx.Model(model.Inbound{}).Find(&inbounds).Error
if err != nil {
return nil, err
}
groups := make(map[string]*shareQuotaGroup)
type memberMeta struct {
member shareQuotaMember
total int64
expiry int64
}
staged := make(map[string][]memberMeta)
for _, inbound := range inbounds {
clients, err := s.GetClients(inbound)
if err != nil || clients == nil {
continue
}
for _, c := range clients {
if !c.ShareQuota || c.SubID == "" || c.Email == "" {
continue
}
staged[c.SubID] = append(staged[c.SubID], memberMeta{
member: shareQuotaMember{Email: c.Email, InboundId: inbound.Id, Tag: inbound.Tag},
total: c.TotalGB,
expiry: c.ExpiryTime,
})
}
}
if len(staged) == 0 {
return groups, nil
}
// Collect every email in any group so we can fetch traffic in a single query.
emails := make([]string, 0)
for _, members := range staged {
for _, m := range members {
emails = append(emails, m.member.Email)
}
}
var trafficRows []xray.ClientTraffic
if len(emails) > 0 {
if err := tx.Model(xray.ClientTraffic{}).Where("email IN (?)", emails).Find(&trafficRows).Error; err != nil {
return nil, err
}
}
usage := make(map[string]*xray.ClientTraffic, len(trafficRows))
for i := range trafficRows {
usage[trafficRows[i].Email] = &trafficRows[i]
}
for subId, members := range staged {
if len(members) < 1 {
continue
}
g := &shareQuotaGroup{}
// Semantics: MaxTotal==0 means unlimited. If any member is unlimited (total==0)
// the group is unlimited; otherwise MaxTotal = max of member totals.
// Same pattern for MinExpiry: 0 means never.
var maxTotal int64
maxTotalSeen := false
anyUnlimited := false
var minExpiry int64
minExpirySeen := false
anyNever := false
for _, m := range members {
g.Members = append(g.Members, m.member)
if m.total == 0 {
anyUnlimited = true
} else if !maxTotalSeen || m.total > maxTotal {
maxTotal = m.total
maxTotalSeen = true
}
if m.expiry == 0 {
anyNever = true
} else if !minExpirySeen || m.expiry < minExpiry {
minExpiry = m.expiry
minExpirySeen = true
}
// Accumulate usage from client_traffics.
if t, ok := usage[m.member.Email]; ok {
g.TotalUp += t.Up
g.TotalDown += t.Down
}
}
if anyUnlimited {
g.MaxTotal = 0
} else {
g.MaxTotal = maxTotal
}
if anyNever {
g.MinExpiry = 0
} else {
g.MinExpiry = minExpiry
}
groups[subId] = g
}
return groups, nil
}
// propagateShareQuota keeps totalGB and expiryTime in sync across every client with a
// matching subId where shareQuota is true. Writes both inbounds.settings JSON and the
// affected client_traffics rows inside the provided transaction.
func (s *InboundService) propagateShareQuota(tx *gorm.DB, subId string, totalGB, expiryTime int64) error {
if subId == "" {
return nil
}
var inbounds []*model.Inbound
err := tx.Model(model.Inbound{}).Where(`id in (
SELECT DISTINCT inbounds.id
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE JSON_EXTRACT(client.value, '$.subId') = ?
AND (JSON_EXTRACT(client.value, '$.shareQuota') = 1
OR JSON_EXTRACT(client.value, '$.shareQuota') = 'true'
OR JSON_EXTRACT(client.value, '$.shareQuota') = TRUE)
)`, subId).Find(&inbounds).Error
if err != nil {
return err
}
if len(inbounds) == 0 {
return nil
}
nowMs := time.Now().Unix() * 1000
touchedEmails := make([]string, 0)
for ibIdx := range inbounds {
var settings map[string]any
if err := json.Unmarshal([]byte(inbounds[ibIdx].Settings), &settings); err != nil {
return err
}
rawClients, ok := settings["clients"].([]any)
if !ok {
continue
}
changed := false
for cIdx := range rawClients {
cm, ok := rawClients[cIdx].(map[string]any)
if !ok {
continue
}
// Type-safe field checks: JSON numbers unmarshal as float64.
cSub, _ := cm["subId"].(string)
cShare, _ := cm["shareQuota"].(bool)
if cSub != subId || !cShare {
continue
}
cm["totalGB"] = totalGB
cm["expiryTime"] = expiryTime
cm["updated_at"] = nowMs
rawClients[cIdx] = cm
changed = true
if email, ok := cm["email"].(string); ok && email != "" {
touchedEmails = append(touchedEmails, email)
}
}
if !changed {
continue
}
settings["clients"] = rawClients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
inbounds[ibIdx].Settings = string(newSettings)
if err := tx.Save(inbounds[ibIdx]).Error; err != nil {
return err
}
}
if len(touchedEmails) == 0 {
return nil
}
return tx.Model(xray.ClientTraffic{}).
Where("email IN (?)", touchedEmails).
Updates(map[string]any{
"total": totalGB,
"expiry_time": expiryTime,
}).Error
}
// shareQuotaGroupValues returns the canonical totalGB/expiryTime of an existing group
// (same subId, shareQuota=true) if one exists, and a bool indicating whether it exists.
// Used so that a client joining an existing group adopts the group's values instead of
// resetting every sibling to the new arrival's values.
func (s *InboundService) shareQuotaGroupValues(tx *gorm.DB, subId string, excludeEmail string) (totalGB int64, expiryTime int64, exists bool, err error) {
if subId == "" {
return 0, 0, false, nil
}
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where(`id in (
SELECT DISTINCT inbounds.id
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE JSON_EXTRACT(client.value, '$.subId') = ?
AND (JSON_EXTRACT(client.value, '$.shareQuota') = 1
OR JSON_EXTRACT(client.value, '$.shareQuota') = 'true'
OR JSON_EXTRACT(client.value, '$.shareQuota') = TRUE)
)`, subId).Find(&inbounds).Error
if err != nil {
return 0, 0, false, err
}
for _, ib := range inbounds {
clients, errC := s.GetClients(ib)
if errC != nil {
continue
}
for _, c := range clients {
if c.SubID != subId || !c.ShareQuota {
continue
}
if excludeEmail != "" && c.Email == excludeEmail {
continue
}
return c.TotalGB, c.ExpiryTime, true, nil
}
}
return 0, 0, false, nil
}
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
now := time.Now().Unix() * 1000
needRestart := false
// First pass: handle share-quota groups so members are disabled together and
// excluded from the per-client pass below.
handledEmails := make(map[string]bool)
groups, err := s.computeShareQuotaGroups(tx)
if err != nil {
return false, 0, err
}
groupDepletedEmails := make([]string, 0)
if len(groups) > 0 && p != nil {
s.xrayApi.Init(p.GetAPIPort())
for _, g := range groups {
depleted := (g.MaxTotal > 0 && g.TotalUp+g.TotalDown >= g.MaxTotal) ||
(g.MinExpiry > 0 && g.MinExpiry <= now)
if !depleted {
continue
}
for _, m := range g.Members {
if handledEmails[m.Email] {
continue
}
err1 := s.xrayApi.RemoveUser(m.Tag, m.Email)
if err1 == nil {
logger.Debug("Shared-quota client disabled by api:", m.Email)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", m.Email)) {
logger.Debug("Shared-quota user already disabled. Nothing to do.")
} else {
logger.Debug("Error disabling shared-quota client by api:", err1)
needRestart = true
}
handledEmails[m.Email] = true
groupDepletedEmails = append(groupDepletedEmails, m.Email)
}
}
s.xrayApi.Close()
} else if len(groups) > 0 {
// No p (Xray process) available — still collect emails so DB state is consistent.
for _, g := range groups {
depleted := (g.MaxTotal > 0 && g.TotalUp+g.TotalDown >= g.MaxTotal) ||
(g.MinExpiry > 0 && g.MinExpiry <= now)
if !depleted {
continue
}
for _, m := range g.Members {
if handledEmails[m.Email] {
continue
}
handledEmails[m.Email] = true
groupDepletedEmails = append(groupDepletedEmails, m.Email)
}
}
}
if len(groupDepletedEmails) > 0 {
if err := tx.Model(xray.ClientTraffic{}).
Where("email IN (?)", groupDepletedEmails).
Update("enable", false).Error; err != nil {
return false, 0, err
}
}
// Build exclusion list for the per-client pass. Also exclude every email in any
// non-depleted share-quota group — their quota is pooled and mustn't be judged
// individually on the single-row thresholds.
excludedEmails := make([]string, 0)
for _, g := range groups {
for _, m := range g.Members {
if !handledEmails[m.Email] {
excludedEmails = append(excludedEmails, m.Email)
}
}
}
excludedEmails = append(excludedEmails, groupDepletedEmails...)
if p != nil {
var results []struct {
Tag string
Email string
}
err := tx.Table("inbounds").
q := tx.Table("inbounds").
Select("inbounds.tag, client_traffics.email").
Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
Where("((client_traffics.total > 0 AND client_traffics.up + client_traffics.down >= client_traffics.total) OR (client_traffics.expiry_time > 0 AND client_traffics.expiry_time <= ?)) AND client_traffics.enable = ?", now, true).
Scan(&results).Error
if err != nil {
Where("((client_traffics.total > 0 AND client_traffics.up + client_traffics.down >= client_traffics.total) OR (client_traffics.expiry_time > 0 AND client_traffics.expiry_time <= ?)) AND client_traffics.enable = ?", now, true)
if len(excludedEmails) > 0 {
q = q.Where("client_traffics.email NOT IN (?)", excludedEmails)
}
if err := q.Scan(&results).Error; err != nil {
return false, 0, err
}
s.xrayApi.Init(p.GetAPIPort())
@ -1563,22 +1979,21 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
logger.Debug("User is already disabled. Nothing to do more...")
} else {
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
logger.Debug("User is already disabled. Nothing to do more...")
} else {
logger.Debug("Error in disabling client by api:", err1)
needRestart = true
}
logger.Debug("Error in disabling client by api:", err1)
needRestart = true
}
}
}
s.xrayApi.Close()
}
result := tx.Model(xray.ClientTraffic{}).
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
Update("enable", false)
err := result.Error
count := result.RowsAffected
updateQ := tx.Model(xray.ClientTraffic{}).
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true)
if len(excludedEmails) > 0 {
updateQ = updateQ.Where("email NOT IN (?)", excludedEmails)
}
result := updateQ.Update("enable", false)
err = result.Error
count := result.RowsAffected + int64(len(groupDepletedEmails))
return needRestart, count, err
}

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "استخدم شهادة البانل"
"telegramDesc" = "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو (@userinfobot)"
"subscriptionDesc" = "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "معلومات"
"same" = "نفسه"
"inboundData" = "بيانات الإدخال"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Set Cert from Panel"
"telegramDesc" = "Please provide Telegram Chat ID. (use '/id' command in the bot) or (@userinfobot)"
"subscriptionDesc" = "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Info"
"same" = "Same"
"inboundData" = "Inbound's Data"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Establecer certificado desde el panel"
"telegramDesc" = "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o (@userinfobot)"
"subscriptionDesc" = "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Info"
"same" = "misma"
"inboundData" = "Datos de entrada"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "استفاده از گواهی پنل"
"telegramDesc" = "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)"
"subscriptionDesc" = "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید"
"shareQuota" = "اشتراک حجم بین اینباندها"
"shareQuotaDesc" = "مقدار حجم و تاریخ انقضا با سایر کلاینت‌هایی که همین subId را دارند و گزینه اشتراک حجم‌شان فعال است ترکیب می‌شود."
"info" = "اطلاعات"
"same" = "همسان"
"inboundData" = "داده‌های ورودی"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Atur Sertifikat dari Panel"
"telegramDesc" = "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau (@userinfobot)"
"subscriptionDesc" = "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Info"
"same" = "Sama"
"inboundData" = "Data Masuk"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "パネル設定から証明書を設定"
"telegramDesc" = "TelegramチャットIDを提供してください。ボットで'/id'コマンドを使用)または(@userinfobot"
"subscriptionDesc" = "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。"
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "情報"
"same" = "同じ"
"inboundData" = "インバウンドデータ"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Definir Certificado pelo Painel"
"telegramDesc" = "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou (@userinfobot)"
"subscriptionDesc" = "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Informações"
"same" = "Igual"
"inboundData" = "Dados do Inbound"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Установить сертификат панели"
"telegramDesc" = "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или (@userinfobot)"
"subscriptionDesc" = "Вы можете найти свою ссылку подписки в разделе 'Подробнее'"
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Информация"
"same" = "Тот же"
"inboundData" = "Данные подключений"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Panelden Sertifikayı Ayarla"
"telegramDesc" = "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya (@userinfobot)"
"subscriptionDesc" = "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Bilgi"
"same" = "Aynı"
"inboundData" = "Gelenin Verileri"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Установити сертифікат з панелі"
"telegramDesc" = "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або (@userinfobot)"
"subscriptionDesc" = "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів."
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Інформація"
"same" = "Те саме"
"inboundData" = "Вхідні дані"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "Đặt chứng chỉ từ bảng điều khiển"
"telegramDesc" = "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc (@userinfobot)"
"subscriptionDesc" = "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau"
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Thông tin"
"same" = "Giống nhau"
"inboundData" = "Dữ liệu gửi đến"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "从面板设置证书"
"telegramDesc" = "请提供Telegram聊天ID。在机器人中使用'/id'命令)或(@userinfobot"
"subscriptionDesc" = "要找到你的订阅 URL请导航到“详细信息”。此外你可以为多个客户端使用相同的名称。"
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "信息"
"same" = "相同"
"inboundData" = "入站数据"

View file

@ -281,6 +281,8 @@
"setDefaultCert" = "從面板設定證書"
"telegramDesc" = "請提供Telegram聊天ID。在機器人中使用'/id'命令)或(@userinfobot"
"subscriptionDesc" = "要找到你的訂閱 URL請導航到“詳細資訊”。此外你可以為多個客戶端使用相同的名稱。"
"shareQuota" = "Share quota across inbounds"
"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "資訊"
"same" = "相同"
"inboundData" = "入站資料"