mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
add share quote between different clients from different inbounds with same subId
This commit is contained in:
parent
a4b1b3d06d
commit
462cf857e8
20 changed files with 649 additions and 45 deletions
|
|
@ -1 +1 @@
|
|||
2.9.2
|
||||
2.9.2-sharequota1
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" = "بيانات الإدخال"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -281,6 +281,8 @@
|
|||
"setDefaultCert" = "استفاده از گواهی پنل"
|
||||
"telegramDesc" = "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)"
|
||||
"subscriptionDesc" = "شما میتوانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین میتوانید از همین نام برای چندین کاربر استفادهکنید"
|
||||
"shareQuota" = "اشتراک حجم بین اینباندها"
|
||||
"shareQuotaDesc" = "مقدار حجم و تاریخ انقضا با سایر کلاینتهایی که همین subId را دارند و گزینه اشتراک حجمشان فعال است ترکیب میشود."
|
||||
"info" = "اطلاعات"
|
||||
"same" = "همسان"
|
||||
"inboundData" = "دادههای ورودی"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "インバウンドデータ"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "Данные подключений"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "Вхідні дані"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "入站数据"
|
||||
|
|
|
|||
|
|
@ -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" = "入站資料"
|
||||
|
|
|
|||
Loading…
Reference in a new issue