mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat: add aggregated subscription traffic tracking and display support
This commit is contained in:
parent
2928b52b04
commit
2002b84b19
21 changed files with 466 additions and 24 deletions
|
|
@ -182,6 +182,7 @@ type Client struct {
|
|||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
SubTotalGB int64 `json:"subTotalGB" form:"subTotalGB"` // Shared subscription traffic limit in bytes (0 = disabled)
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
|
|
|
|||
|
|
@ -2483,6 +2483,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
|
|||
email = RandomUtil.randomLowerAndNum(8),
|
||||
limitIp = 0,
|
||||
totalGB = 0,
|
||||
subTotalGB = 0,
|
||||
expiryTime = 0,
|
||||
enable = true,
|
||||
tgId = '',
|
||||
|
|
@ -2496,6 +2497,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
|
|||
this.email = email;
|
||||
this.limitIp = limitIp;
|
||||
this.totalGB = totalGB;
|
||||
this.subTotalGB = subTotalGB;
|
||||
this.expiryTime = expiryTime;
|
||||
this.enable = enable;
|
||||
this.tgId = tgId;
|
||||
|
|
@ -2511,6 +2513,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
|
|||
json.email,
|
||||
json.limitIp,
|
||||
json.totalGB,
|
||||
json.subTotalGB,
|
||||
json.expiryTime,
|
||||
json.enable,
|
||||
json.tgId,
|
||||
|
|
@ -2527,6 +2530,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
|
|||
email: this.email,
|
||||
limitIp: this.limitIp,
|
||||
totalGB: this.totalGB,
|
||||
subTotalGB: this.subTotalGB,
|
||||
expiryTime: this.expiryTime,
|
||||
enable: this.enable,
|
||||
tgId: this.tgId,
|
||||
|
|
@ -2563,6 +2567,14 @@ Inbound.ClientBase = class extends XrayCommonClass {
|
|||
set _totalGB(gb) {
|
||||
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
|
||||
get _subTotalGB() {
|
||||
return NumberFormatter.toFixed(this.subTotalGB / SizeFormatter.ONE_GB, 2);
|
||||
}
|
||||
|
||||
set _subTotalGB(gb) {
|
||||
this.subTotalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||
}
|
||||
};
|
||||
|
||||
Inbound.VmessSettings = class extends Inbound.Settings {
|
||||
|
|
@ -2608,9 +2620,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, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.id = id;
|
||||
this.security = security;
|
||||
}
|
||||
|
|
@ -2725,9 +2737,9 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
|
|||
flow = '',
|
||||
reverseTag = '',
|
||||
reverseSniffing = new Sniffing(),
|
||||
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.id = id;
|
||||
this.flow = flow;
|
||||
this.reverseTag = reverseTag;
|
||||
|
|
@ -2818,9 +2830,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, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
|
@ -2902,9 +2914,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, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.method = method;
|
||||
this.password = password;
|
||||
}
|
||||
|
|
@ -2952,9 +2964,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, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
|
||||
) {
|
||||
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,14 @@ export const sections = [
|
|||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getSubTraffic/:subId',
|
||||
summary: 'Aggregated traffic counters for all clients sharing a subscription ID. Returns combined upload, download, the shared quota (subTotalGB), and the number of linked clients.',
|
||||
params: [
|
||||
{ name: 'subId', in: 'path', type: 'string', desc: 'Subscription ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
|
|
|
|||
|
|
@ -86,6 +86,17 @@ const totalGB = computed({
|
|||
},
|
||||
});
|
||||
|
||||
const subTotalGB = computed({
|
||||
get: () => {
|
||||
if (!client.value || !client.value.subTotalGB) return 0;
|
||||
return Math.round((client.value.subTotalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
||||
},
|
||||
set: (gb) => {
|
||||
if (!client.value) return;
|
||||
client.value.subTotalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
||||
},
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (props.mode !== 'edit' || !client.value) return false;
|
||||
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
||||
|
|
@ -340,6 +351,13 @@ const title = computed(() =>
|
|||
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="subEnable && client.subId">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('subscription.totalQuota') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="subTotalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
||||
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
||||
|
|
|
|||
|
|
@ -361,6 +361,10 @@ function confirmBulkDelete() {
|
|||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
|
||||
</tr>
|
||||
<tr v-if="client.subTotalGB > 0">
|
||||
<td>{{ t('subscription.totalQuota') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(client.subTotalGB) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,38 @@ const refreshing = ref(false);
|
|||
const clientIpsArray = ref([]);
|
||||
const clientIpsText = ref('');
|
||||
|
||||
// Shared subscription quota state.
|
||||
const subTrafficInfo = ref(null);
|
||||
|
||||
async function loadSubTraffic(subId) {
|
||||
if (!subId) { subTrafficInfo.value = null; return; }
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/getSubTraffic/${subId}`);
|
||||
if (msg?.success && msg.obj) {
|
||||
subTrafficInfo.value = msg.obj;
|
||||
} else {
|
||||
subTrafficInfo.value = null;
|
||||
}
|
||||
} catch (_e) {
|
||||
subTrafficInfo.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSubRemaining() {
|
||||
if (!subTrafficInfo.value || !subTrafficInfo.value.total) return '-';
|
||||
const remained = subTrafficInfo.value.total - subTrafficInfo.value.up - subTrafficInfo.value.down;
|
||||
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
||||
}
|
||||
|
||||
function subStatsColor() {
|
||||
if (!subTrafficInfo.value || !subTrafficInfo.value.total) return 'default';
|
||||
return ColorUtils.usageColor(
|
||||
subTrafficInfo.value.up + subTrafficInfo.value.down,
|
||||
props.trafficDiff,
|
||||
subTrafficInfo.value.total,
|
||||
);
|
||||
}
|
||||
|
||||
// === Status flags shown as tags ====================================
|
||||
const isEnable = computed(() => {
|
||||
if (clientSettings.value) return !!clientSettings.value.enable;
|
||||
|
|
@ -246,6 +278,12 @@ watch(() => props.open, (next) => {
|
|||
) {
|
||||
loadClientIps();
|
||||
}
|
||||
|
||||
// Load shared subscription traffic if client has subTotalGB.
|
||||
subTrafficInfo.value = null;
|
||||
if (clientSettings.value?.subTotalGB > 0 && clientSettings.value?.subId) {
|
||||
loadSubTraffic(clientSettings.value.subId);
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
|
|
@ -380,6 +418,7 @@ const showSubscriptionTab = computed(
|
|||
<th>{{ t('remained') }}</th>
|
||||
<th>{{ t('pages.inbounds.totalUsage') }}</th>
|
||||
<th>{{ t('pages.inbounds.expireDate') }}</th>
|
||||
<th v-if="subTrafficInfo && subTrafficInfo.total > 0">{{ t('subscription.subRemained') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -409,6 +448,10 @@ const showSubscriptionTab = computed(
|
|||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</td>
|
||||
<td v-if="subTrafficInfo && subTrafficInfo.total > 0">
|
||||
<a-tag :color="subStatsColor()">{{ getSubRemaining() }}</a-tag>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(subTrafficInfo.total) }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||
g.GET("/get/:id", a.getInbound)
|
||||
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
||||
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
||||
g.GET("/getSubTraffic/:subId", a.getSubTraffic)
|
||||
g.GET("/getSubLinks/:subId", a.getSubLinks)
|
||||
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
|
||||
|
||||
|
|
@ -143,6 +144,17 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) {
|
|||
jsonObj(c, clientTraffics, nil)
|
||||
}
|
||||
|
||||
// getSubTraffic retrieves aggregated traffic info for all clients sharing a SubID.
|
||||
func (a *InboundController) getSubTraffic(c *gin.Context) {
|
||||
subId := c.Param("subId")
|
||||
info, err := a.inboundService.GetSubTrafficInfo(subId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, info, nil)
|
||||
}
|
||||
|
||||
// addInbound creates a new inbound configuration.
|
||||
func (a *InboundController) addInbound(c *gin.Context) {
|
||||
inbound := &model.Inbound{}
|
||||
|
|
|
|||
|
|
@ -1859,13 +1859,21 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
|||
disabledClientsCount = count
|
||||
}
|
||||
|
||||
needRestart3, count, err := s.disableSubQuotaClients(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling sub-quota clients:", err)
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v clients disabled by sub quota", count)
|
||||
disabledClientsCount += count
|
||||
}
|
||||
|
||||
needRestart2, count, err := s.disableInvalidInbounds(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling invalid inbounds:", err)
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v inbounds disabled", count)
|
||||
}
|
||||
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
|
||||
return needRestart0 || needRestart1 || needRestart2 || needRestart3, disabledClientsCount > 0, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||
|
|
@ -2338,6 +2346,290 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
|||
return needRestart, count, nil
|
||||
}
|
||||
|
||||
// SubTrafficInfo holds aggregated traffic data for all clients sharing a SubID.
|
||||
type SubTrafficInfo struct {
|
||||
SubId string `json:"subId"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
Total int64 `json:"total"` // subTotalGB limit (bytes)
|
||||
Clients int `json:"clients"` // number of client emails sharing this subId
|
||||
}
|
||||
|
||||
// GetSubTrafficInfo returns aggregated traffic data for all clients sharing
|
||||
// the given subId. The Total field is the first non-zero subTotalGB found
|
||||
// across any sibling client.
|
||||
func (s *InboundService) GetSubTrafficInfo(subId string) (*SubTrafficInfo, error) {
|
||||
if subId == "" {
|
||||
return nil, common.NewError("empty subId")
|
||||
}
|
||||
db := database.GetDB()
|
||||
|
||||
// 1. Find all emails + subTotalGB for this subId.
|
||||
var rows []struct {
|
||||
Email string
|
||||
SubTotalGB int64 `gorm:"column:sub_total_gb"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT JSON_EXTRACT(client.value, '$.email') AS email,
|
||||
COALESCE(JSON_EXTRACT(client.value, '$.subTotalGB'), 0) AS sub_total_gb
|
||||
FROM inbounds,
|
||||
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
|
||||
WHERE REPLACE(JSON_EXTRACT(client.value, '$.subId'), '"', '') = ?
|
||||
`, subId).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return &SubTrafficInfo{SubId: subId}, nil
|
||||
}
|
||||
|
||||
emails := make([]string, 0, len(rows))
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
var subTotal int64
|
||||
for _, r := range rows {
|
||||
email := strings.Trim(r.Email, "\"")
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(email)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
emails = append(emails, email)
|
||||
if subTotal == 0 && r.SubTotalGB > 0 {
|
||||
subTotal = r.SubTotalGB
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sum traffic from client_traffics for those emails.
|
||||
var up, down int64
|
||||
for _, batch := range chunkStrings(emails, sqliteMaxVars) {
|
||||
var result struct {
|
||||
Up int64
|
||||
Down int64
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Select("COALESCE(SUM(up),0) AS up, COALESCE(SUM(down),0) AS down").
|
||||
Where("email IN ?", batch).Scan(&result).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
up += result.Up
|
||||
down += result.Down
|
||||
}
|
||||
|
||||
return &SubTrafficInfo{
|
||||
SubId: subId,
|
||||
Up: up,
|
||||
Down: down,
|
||||
Total: subTotal,
|
||||
Clients: len(emails),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// disableSubQuotaClients checks all SubID groups for shared quota violations.
|
||||
// If the aggregate traffic (up+down) across all clients sharing a SubID
|
||||
// exceeds the subTotalGB configured on any sibling, ALL clients in the group
|
||||
// are disabled. Only considers enabled clients on local-node inbounds.
|
||||
func (s *InboundService) disableSubQuotaClients(tx *gorm.DB) (bool, int64, error) {
|
||||
// 1. Collect every (email, subId, subTotalGB) tuple from inbound settings.
|
||||
var allRows []struct {
|
||||
InboundId int
|
||||
Tag string
|
||||
Email string
|
||||
SubID string `gorm:"column:sub_id"`
|
||||
SubTotalGB int64 `gorm:"column:sub_total_gb"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT inbounds.id AS inbound_id,
|
||||
inbounds.tag AS tag,
|
||||
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
|
||||
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: collect emails and find the first non-zero subTotalGB.
|
||||
type subGroup struct {
|
||||
emails map[string]struct{}
|
||||
subTotalGB int64
|
||||
members []struct{ InboundId int; Tag, Email string }
|
||||
}
|
||||
groups := make(map[string]*subGroup)
|
||||
for _, r := range allRows {
|
||||
subId := strings.TrimSpace(r.SubID)
|
||||
if subId == "" {
|
||||
continue
|
||||
}
|
||||
email := strings.Trim(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 g.subTotalGB == 0 && r.SubTotalGB > 0 {
|
||||
g.subTotalGB = r.SubTotalGB
|
||||
}
|
||||
g.members = append(g.members, struct{ InboundId int; Tag, Email string }{
|
||||
InboundId: r.InboundId, Tag: r.Tag, Email: email,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. For each group with a quota, check if aggregate traffic exceeds it.
|
||||
type disableTarget struct {
|
||||
InboundId int
|
||||
Tag string
|
||||
Email string
|
||||
}
|
||||
var toDisable []disableTarget
|
||||
|
||||
for _, g := range groups {
|
||||
if g.subTotalGB <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sum traffic for all emails in this subId group.
|
||||
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("email IN ? AND enable = ?", batch, true).
|
||||
Scan(&sum).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
totalUsed += sum.Total
|
||||
}
|
||||
|
||||
if totalUsed < g.subTotalGB {
|
||||
continue
|
||||
}
|
||||
|
||||
// Quota exceeded — mark all members for disabling.
|
||||
for _, m := range g.members {
|
||||
toDisable = append(toDisable, disableTarget{
|
||||
InboundId: m.InboundId, Tag: m.Tag, Email: m.Email,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(toDisable) == 0 {
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
// 4. Remove users from Xray runtime.
|
||||
needRestart := false
|
||||
if p != nil {
|
||||
s.xrayApi.Init(p.GetAPIPort())
|
||||
for _, t := range toDisable {
|
||||
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
|
||||
if err1 == nil {
|
||||
logger.Debug("Sub-quota client disabled by api:", t.Email)
|
||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", t.Email)) {
|
||||
logger.Debug("Sub-quota user already disabled:", t.Email)
|
||||
} else {
|
||||
logger.Debug("Error disabling sub-quota client by api:", err1)
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
s.xrayApi.Close()
|
||||
}
|
||||
|
||||
// 5. Disable client_traffics rows.
|
||||
disableEmails := make([]string, 0, len(toDisable))
|
||||
for _, t := range toDisable {
|
||||
disableEmails = append(disableEmails, t.Email)
|
||||
}
|
||||
uniqDisable := uniqueNonEmptyStrings(disableEmails)
|
||||
var totalDisabled int64
|
||||
for _, batch := range chunkStrings(uniqDisable, sqliteMaxVars) {
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ? AND enable = ?", batch, true).
|
||||
Update("enable", false)
|
||||
if result.Error != nil {
|
||||
logger.Warning("disableSubQuotaClients update client_traffics:", result.Error)
|
||||
}
|
||||
totalDisabled += result.RowsAffected
|
||||
}
|
||||
|
||||
// 6. Update inbound settings JSON to set enable=false on affected clients.
|
||||
inboundEmailMap := make(map[int]map[string]struct{})
|
||||
for _, t := range toDisable {
|
||||
if inboundEmailMap[t.InboundId] == nil {
|
||||
inboundEmailMap[t.InboundId] = make(map[string]struct{})
|
||||
}
|
||||
inboundEmailMap[t.InboundId][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("disableSubQuotaClients fetch inbounds:", err)
|
||||
return needRestart, totalDisabled, nil
|
||||
}
|
||||
now := time.Now().Unix() * 1000
|
||||
dirty := make([]*model.Inbound, 0, len(inbounds))
|
||||
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 _, shouldDisable := emailSet[email]; !shouldDisable {
|
||||
continue
|
||||
}
|
||||
c["enable"] = false
|
||||
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("disableSubQuotaClients update inbound settings:", err)
|
||||
}
|
||||
}
|
||||
|
||||
return needRestart, totalDisabled, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInboundTags() (string, error) {
|
||||
db := database.GetDB()
|
||||
var inboundTags []string
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "نشط",
|
||||
"inactive": "غير نشط",
|
||||
"unlimited": "غير محدود",
|
||||
"noExpiry": "بدون انتهاء"
|
||||
"noExpiry": "بدون انتهاء",
|
||||
"subRemained": "المتبقي (الاشتراك)",
|
||||
"subTotalFlow": "إجمالي حركة الاشتراك",
|
||||
"subUsage": "استخدام الاشتراك",
|
||||
"subDepleted": "الاشتراك منتهي"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "الثيم",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"unlimited": "Unlimited",
|
||||
"noExpiry": "No expiry"
|
||||
"noExpiry": "No expiry",
|
||||
"subRemained": "Remained (Sub.)",
|
||||
"subTotalFlow": "Sub. Total Flow",
|
||||
"subUsage": "Sub. Usage",
|
||||
"subDepleted": "Sub. Depleted"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Theme",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"unlimited": "Ilimitado",
|
||||
"noExpiry": "Sin caducidad"
|
||||
"noExpiry": "Sin caducidad",
|
||||
"subRemained": "Restante (Sub.)",
|
||||
"subTotalFlow": "Flujo Total Sub.",
|
||||
"subUsage": "Uso Sub.",
|
||||
"subDepleted": "Sub. Agotada"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "فعال",
|
||||
"inactive": "غیرفعال",
|
||||
"unlimited": "نامحدود",
|
||||
"noExpiry": "بدون انقضا"
|
||||
"noExpiry": "بدون انقضا",
|
||||
"subRemained": "باقیمانده (اشتراک)",
|
||||
"subTotalFlow": "حجم کلی اشتراک",
|
||||
"subUsage": "مصرف اشتراک",
|
||||
"subDepleted": "اشتراک تمام شده"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "تم",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Aktif",
|
||||
"inactive": "Nonaktif",
|
||||
"unlimited": "Tanpa batas",
|
||||
"noExpiry": "Tanpa kedaluwarsa"
|
||||
"noExpiry": "Tanpa kedaluwarsa",
|
||||
"subRemained": "Sisa (Langganan)",
|
||||
"subTotalFlow": "Total Aliran Langg.",
|
||||
"subUsage": "Penggunaan Langg.",
|
||||
"subDepleted": "Langg. Habis"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "有効",
|
||||
"inactive": "無効",
|
||||
"unlimited": "無制限",
|
||||
"noExpiry": "期限なし"
|
||||
"noExpiry": "期限なし",
|
||||
"subRemained": "残り (サブ.)",
|
||||
"subTotalFlow": "サブ. 合計フロー",
|
||||
"subUsage": "サブ. 使用量",
|
||||
"subDepleted": "サブ. 消耗済み"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "テーマ",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Ativo",
|
||||
"inactive": "Inativo",
|
||||
"unlimited": "Ilimitado",
|
||||
"noExpiry": "Sem validade"
|
||||
"noExpiry": "Sem validade",
|
||||
"subRemained": "Restante (Assin.)",
|
||||
"subTotalFlow": "Fluxo Total Assin.",
|
||||
"subUsage": "Uso Assin.",
|
||||
"subDepleted": "Assin. Esgotada"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Активна",
|
||||
"inactive": "Неактивна",
|
||||
"unlimited": "Неограниченно",
|
||||
"noExpiry": "Бессрочно"
|
||||
"noExpiry": "Бессрочно",
|
||||
"subRemained": "Осталось (Подп.)",
|
||||
"subTotalFlow": "Общий лимит подписки",
|
||||
"subUsage": "Использование подписки",
|
||||
"subDepleted": "Подписка исчерпана"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Тема",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Aktif",
|
||||
"inactive": "Pasif",
|
||||
"unlimited": "Sınırsız",
|
||||
"noExpiry": "Süresiz"
|
||||
"noExpiry": "Süresiz",
|
||||
"subRemained": "Kalan (Abo.)",
|
||||
"subTotalFlow": "Abo. Toplam Akış",
|
||||
"subUsage": "Abo. Kullanım",
|
||||
"subDepleted": "Abo. Tükendi"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Активна",
|
||||
"inactive": "Неактивна",
|
||||
"unlimited": "Безліміт",
|
||||
"noExpiry": "Без строку"
|
||||
"noExpiry": "Без строку",
|
||||
"subRemained": "Залишок (Підп.)",
|
||||
"subTotalFlow": "Загальний ліміт підписки",
|
||||
"subUsage": "Використання підписки",
|
||||
"subDepleted": "Підписку вичерпано"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Тема",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "Hoạt động",
|
||||
"inactive": "Không hoạt động",
|
||||
"unlimited": "Không giới hạn",
|
||||
"noExpiry": "Không hết hạn"
|
||||
"noExpiry": "Không hết hạn",
|
||||
"subRemained": "Còn lại (Đăng ký)",
|
||||
"subTotalFlow": "Tổng lưu lượng đăng ký",
|
||||
"subUsage": "Sử dụng đăng ký",
|
||||
"subDepleted": "Đăng ký đã hết"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Chủ đề",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "启用",
|
||||
"inactive": "停用",
|
||||
"unlimited": "无限制",
|
||||
"noExpiry": "无到期"
|
||||
"noExpiry": "无到期",
|
||||
"subRemained": "剩余 (订阅)",
|
||||
"subTotalFlow": "订阅总流量",
|
||||
"subUsage": "订阅用量",
|
||||
"subDepleted": "订阅已耗尽"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "主题",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
"active": "啟用",
|
||||
"inactive": "停用",
|
||||
"unlimited": "無限制",
|
||||
"noExpiry": "無到期"
|
||||
"noExpiry": "無到期",
|
||||
"subRemained": "剩餘 (訂閱)",
|
||||
"subTotalFlow": "訂閱總流量",
|
||||
"subUsage": "訂閱用量",
|
||||
"subDepleted": "訂閱已耗盡"
|
||||
},
|
||||
"menu": {
|
||||
"theme": "主題",
|
||||
|
|
|
|||
Loading…
Reference in a new issue