feat: add inbound traffic multiplier

This commit is contained in:
byang37 2026-05-27 00:58:04 +08:00
parent 20edaee8ed
commit 8c74a4eff5
22 changed files with 359 additions and 46 deletions

View file

@ -55,6 +55,7 @@ type Inbound struct {
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
TrafficMultiplier float64 `json:"trafficMultiplier" form:"trafficMultiplier" gorm:"default:1"` // Multiplier for inbound/client traffic accounting
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
// Xray configuration fields // Xray configuration fields

View file

@ -312,6 +312,7 @@
"remark": "VLESS-443", "remark": "VLESS-443",
"enable": true, "enable": true,
"expiryTime": 0, "expiryTime": 0,
"trafficMultiplier": 1,
"listen": "", "listen": "",
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
@ -500,6 +501,7 @@
"protocol": "vless", "protocol": "vless",
"expiryTime": 0, "expiryTime": 0,
"total": 0, "total": 0,
"trafficMultiplier": 1,
"settings": { "settings": {
"clients": [ "clients": [
{ {

View file

@ -31,6 +31,7 @@ export type DBInboundInit = Partial<{
expiryTime: number; expiryTime: number;
trafficReset: string; trafficReset: string;
lastTrafficResetTime: number; lastTrafficResetTime: number;
trafficMultiplier: number;
listen: string; listen: string;
port: number; port: number;
protocol: string; protocol: string;
@ -73,6 +74,7 @@ export class DBInbound {
expiryTime: number; expiryTime: number;
trafficReset: string; trafficReset: string;
lastTrafficResetTime: number; lastTrafficResetTime: number;
trafficMultiplier: number;
listen: string; listen: string;
port: number; port: number;
@ -99,6 +101,7 @@ export class DBInbound {
this.expiryTime = 0; this.expiryTime = 0;
this.trafficReset = "never"; this.trafficReset = "never";
this.lastTrafficResetTime = 0; this.lastTrafficResetTime = 0;
this.trafficMultiplier = 1;
this.listen = ""; this.listen = "";
this.port = 0; this.port = 0;

View file

@ -108,7 +108,7 @@ export const sections: readonly Section[] = [
path: '/panel/api/inbounds/list', path: '/panel/api/inbounds/list',
summary: 'List every inbound owned by the authenticated user, including each inbounds clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.', summary: 'List every inbound owned by the authenticated user, including each inbounds clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
response: response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}', '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "trafficMultiplier": 1,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -137,7 +137,7 @@ export const sections: readonly Section[] = [
path: '/panel/api/inbounds/add', path: '/panel/api/inbounds/add',
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).', summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
body: body:
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}', '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "trafficMultiplier": 1,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}',
errorResponse: errorResponse:
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}', '{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
}, },

View file

@ -940,6 +940,7 @@ export default function InboundFormModal({
expiryTime: form.expiryTime, expiryTime: form.expiryTime,
trafficReset: form.trafficReset, trafficReset: form.trafficReset,
lastTrafficResetTime: form.lastTrafficResetTime || 0, lastTrafficResetTime: form.lastTrafficResetTime || 0,
trafficMultiplier: form.trafficMultiplier || 1,
listen: ib.listen, listen: ib.listen,
port: ib.port, port: ib.port,
protocol: ib.protocol, protocol: ib.protocol,
@ -1052,6 +1053,18 @@ export default function InboundFormModal({
}} }}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.trafficMultiplier')}>
<InputNumber
value={form.trafficMultiplier || 1}
min={0.01}
step={0.1}
onChange={(v) => {
const next = Number(v);
form.trafficMultiplier = next > 0 ? next : 1;
refresh();
}}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.periodicTrafficResetTitle')}> <Form.Item label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select value={form.trafficReset} onChange={(v) => { form.trafficReset = v; refresh(); }}> <Select value={form.trafficReset} onChange={(v) => { form.trafficReset = v; refresh(); }}>
{TRAFFIC_RESETS.map((r) => ( {TRAFFIC_RESETS.map((r) => (

View file

@ -363,6 +363,7 @@ export default function InboundsPage() {
remark: `${dbInbound.remark} (clone)`, remark: `${dbInbound.remark} (clone)`,
enable: false, enable: false,
expiryTime: 0, expiryTime: 0,
trafficMultiplier: dbInbound.trafficMultiplier || 1,
listen: '', listen: '',
port: RandomUtil.randomInteger(10000, 60000), port: RandomUtil.randomInteger(10000, 60000),
protocol: baseInbound.protocol, protocol: baseInbound.protocol,

View file

@ -357,6 +357,7 @@ func wireInbound(ib *model.Inbound) url.Values {
v.Set("remark", ib.Remark) v.Set("remark", ib.Remark)
v.Set("enable", strconv.FormatBool(ib.Enable)) v.Set("enable", strconv.FormatBool(ib.Enable))
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10)) v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
v.Set("trafficMultiplier", strconv.FormatFloat(ib.TrafficMultiplier, 'f', -1, 64))
v.Set("listen", ib.Listen) v.Set("listen", ib.Listen)
v.Set("port", strconv.Itoa(ib.Port)) v.Set("port", strconv.Itoa(ib.Port))
v.Set("protocol", string(ib.Protocol)) v.Set("protocol", string(ib.Protocol))

View file

@ -6,6 +6,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -29,6 +30,69 @@ type InboundService struct {
fallbackService FallbackService fallbackService FallbackService
} }
func normalizeTrafficMultiplier(multiplier float64) float64 {
if multiplier <= 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {
return 1
}
return multiplier
}
func scaleTraffic(value int64, multiplier float64) int64 {
if value == 0 {
return 0
}
scaled := float64(value) * normalizeTrafficMultiplier(multiplier)
if scaled >= float64(math.MaxInt64) {
return math.MaxInt64
}
if scaled <= float64(math.MinInt64) {
return math.MinInt64
}
return int64(math.Round(scaled))
}
func addScaledTraffic(current int64, value int64, multiplier float64) int64 {
delta := scaleTraffic(value, multiplier)
if delta > 0 && current > math.MaxInt64-delta {
return math.MaxInt64
}
if delta == math.MinInt64 && current < 0 {
return math.MinInt64
}
if delta < 0 && current < math.MinInt64-delta {
return math.MinInt64
}
return current + delta
}
func saturatedTrafficAddExpr(column string, delta int64) clause.Expr {
if delta > 0 {
return gorm.Expr(
"CASE WHEN "+column+" > ? THEN ? ELSE "+column+" + ? END",
math.MaxInt64-delta,
int64(math.MaxInt64),
delta,
)
}
if delta < 0 {
if delta == math.MinInt64 {
return gorm.Expr(
"CASE WHEN "+column+" < ? THEN ? ELSE "+column+" + ? END",
int64(0),
int64(math.MinInt64),
delta,
)
}
return gorm.Expr(
"CASE WHEN "+column+" < ? THEN ? ELSE "+column+" + ? END",
math.MinInt64-delta,
int64(math.MinInt64),
delta,
)
}
return gorm.Expr(column+" + ?", delta)
}
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) { func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
mgr := runtime.GetManager() mgr := runtime.GetManager()
if mgr == nil { if mgr == nil {
@ -467,6 +531,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol // Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound) s.normalizeStreamSettings(inbound)
inbound.TrafficMultiplier = normalizeTrafficMultiplier(inbound.TrafficMultiplier)
exist, err := s.checkPortConflict(inbound, 0) exist, err := s.checkPortConflict(inbound, 0)
if err != nil { if err != nil {
@ -735,6 +800,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol // Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound) s.normalizeStreamSettings(inbound)
inbound.TrafficMultiplier = normalizeTrafficMultiplier(inbound.TrafficMultiplier)
exist, err := s.checkPortConflict(inbound, inbound.Id) exist, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil { if err != nil {
@ -831,6 +897,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Enable = inbound.Enable oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.TrafficMultiplier = inbound.TrafficMultiplier
oldInbound.Listen = inbound.Listen oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol oldInbound.Protocol = inbound.Protocol
@ -1315,22 +1382,23 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
c, ok := tagToCentral[snapIb.Tag] c, ok := tagToCentral[snapIb.Tag]
if !ok { if !ok {
newIb := model.Inbound{ newIb := model.Inbound{
UserId: defaultUserId, UserId: defaultUserId,
NodeID: &nodeID, NodeID: &nodeID,
Tag: snapIb.Tag, Tag: snapIb.Tag,
Listen: snapIb.Listen, Listen: snapIb.Listen,
Port: snapIb.Port, Port: snapIb.Port,
Protocol: snapIb.Protocol, Protocol: snapIb.Protocol,
Settings: snapIb.Settings, Settings: snapIb.Settings,
StreamSettings: snapIb.StreamSettings, StreamSettings: snapIb.StreamSettings,
Sniffing: snapIb.Sniffing, Sniffing: snapIb.Sniffing,
TrafficReset: snapIb.TrafficReset, TrafficReset: snapIb.TrafficReset,
Enable: snapIb.Enable, Enable: snapIb.Enable,
Remark: snapIb.Remark, Remark: snapIb.Remark,
Total: snapIb.Total, Total: snapIb.Total,
ExpiryTime: snapIb.ExpiryTime, ExpiryTime: snapIb.ExpiryTime,
Up: snapIb.Up, TrafficMultiplier: normalizeTrafficMultiplier(snapIb.TrafficMultiplier),
Down: snapIb.Down, Up: snapIb.Up,
Down: snapIb.Down,
} }
if err := tx.Create(&newIb).Error; err != nil { if err := tx.Create(&newIb).Error; err != nil {
logger.Warning("setRemoteTraffic: create central inbound for tag", snapIb.Tag, "failed:", err) logger.Warning("setRemoteTraffic: create central inbound for tag", snapIb.Tag, "failed:", err)
@ -1342,19 +1410,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
snapMultiplier := normalizeTrafficMultiplier(snapIb.TrafficMultiplier)
updates := map[string]any{ updates := map[string]any{
"enable": snapIb.Enable, "enable": snapIb.Enable,
"remark": snapIb.Remark, "remark": snapIb.Remark,
"listen": snapIb.Listen, "listen": snapIb.Listen,
"port": snapIb.Port, "port": snapIb.Port,
"protocol": snapIb.Protocol, "protocol": snapIb.Protocol,
"total": snapIb.Total, "total": snapIb.Total,
"expiry_time": snapIb.ExpiryTime, "expiry_time": snapIb.ExpiryTime,
"settings": snapIb.Settings, "traffic_multiplier": snapMultiplier,
"stream_settings": snapIb.StreamSettings, "settings": snapIb.Settings,
"sniffing": snapIb.Sniffing, "stream_settings": snapIb.StreamSettings,
"traffic_reset": snapIb.TrafficReset, "sniffing": snapIb.Sniffing,
"traffic_reset": snapIb.TrafficReset,
} }
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) { if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
updates["up"] = snapIb.Up updates["up"] = snapIb.Up
@ -1367,6 +1437,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
c.Port != snapIb.Port || c.Port != snapIb.Port ||
c.Total != snapIb.Total || c.Total != snapIb.Total ||
c.ExpiryTime != snapIb.ExpiryTime || c.ExpiryTime != snapIb.ExpiryTime ||
c.TrafficMultiplier != snapMultiplier ||
c.Enable != snapIb.Enable { c.Enable != snapIb.Enable {
structuralChange = true structuralChange = true
} }
@ -1640,13 +1711,49 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
} }
var err error var err error
tags := make([]string, 0, len(traffics))
seenTags := make(map[string]struct{}, len(traffics))
for _, traffic := range traffics {
if traffic == nil || !traffic.IsInbound || traffic.Tag == "" {
continue
}
if _, ok := seenTags[traffic.Tag]; ok {
continue
}
seenTags[traffic.Tag] = struct{}{}
tags = append(tags, traffic.Tag)
}
multiplierByTag := make(map[string]float64, len(tags))
if len(tags) > 0 {
var rows []struct {
Tag string
TrafficMultiplier float64
}
err = tx.Model(&model.Inbound{}).
Select("tag, traffic_multiplier").
Where("tag IN ? AND node_id IS NULL", tags).
Find(&rows).Error
if err != nil {
return err
}
for _, row := range rows {
multiplierByTag[row.Tag] = normalizeTrafficMultiplier(row.TrafficMultiplier)
}
}
for _, traffic := range traffics { for _, traffic := range traffics {
if traffic == nil {
continue
}
if traffic.IsInbound { if traffic.IsInbound {
multiplier := multiplierByTag[traffic.Tag]
up := scaleTraffic(traffic.Up, multiplier)
down := scaleTraffic(traffic.Down, multiplier)
err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag). err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag).
Updates(map[string]any{ Updates(map[string]any{
"up": gorm.Expr("up + ?", traffic.Up), "up": saturatedTrafficAddExpr("up", up),
"down": gorm.Expr("down + ?", traffic.Down), "down": saturatedTrafficAddExpr("down", down),
}).Error }).Error
if err != nil { if err != nil {
return err return err
@ -1684,6 +1791,36 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
return err return err
} }
inboundIDs := make([]int, 0, len(dbClientTraffics))
seenInboundIDs := make(map[int]struct{}, len(dbClientTraffics))
for _, dbClientTraffic := range dbClientTraffics {
if dbClientTraffic == nil {
continue
}
if _, ok := seenInboundIDs[dbClientTraffic.InboundId]; ok {
continue
}
seenInboundIDs[dbClientTraffic.InboundId] = struct{}{}
inboundIDs = append(inboundIDs, dbClientTraffic.InboundId)
}
multiplierByInboundID := make(map[int]float64, len(inboundIDs))
if len(inboundIDs) > 0 {
var rows []struct {
Id int
TrafficMultiplier float64
}
err = tx.Model(&model.Inbound{}).
Select("id, traffic_multiplier").
Where("id IN ?", inboundIDs).
Find(&rows).Error
if err != nil {
return err
}
for _, row := range rows {
multiplierByInboundID[row.Id] = normalizeTrafficMultiplier(row.TrafficMultiplier)
}
}
// Index by email for O(N) merge — the previous nested loop was O(N²) // Index by email for O(N) merge — the previous nested loop was O(N²)
// and dominated each cron tick on inbounds with thousands of active // and dominated each cron tick on inbounds with thousands of active
// clients (7500 × 7500 = 56M string comparisons every 10 seconds). // clients (7500 × 7500 = 56M string comparisons every 10 seconds).
@ -1699,8 +1836,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
if !ok { if !ok {
continue continue
} }
dbClientTraffics[dbTraffic_index].Up += t.Up multiplier := multiplierByInboundID[dbClientTraffics[dbTraffic_index].InboundId]
dbClientTraffics[dbTraffic_index].Down += t.Down dbClientTraffics[dbTraffic_index].Up = addScaledTraffic(dbClientTraffics[dbTraffic_index].Up, t.Up, multiplier)
dbClientTraffics[dbTraffic_index].Down = addScaledTraffic(dbClientTraffics[dbTraffic_index].Down, t.Down, multiplier)
if t.Up+t.Down > 0 { if t.Up+t.Down > 0 {
dbClientTraffics[dbTraffic_index].LastOnline = now dbClientTraffics[dbTraffic_index].LastOnline = now
} }

View file

@ -0,0 +1,141 @@
package service
import (
"math"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
)
func TestAddTrafficAppliesInboundTrafficMultiplier(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
inbound := model.Inbound{
UserId: 1,
Tag: "inbound-test",
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"11111111-1111-1111-1111-111111111111","email":"alice@example.com","enable":true}],"decryption":"none"}`,
TrafficMultiplier: 2,
Enable: true,
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: inbound.Id,
Email: "alice@example.com",
Enable: true,
}).Error; err != nil {
t.Fatalf("create client traffic: %v", err)
}
s := InboundService{}
_, _, err := s.AddTraffic(
[]*xray.Traffic{{
IsInbound: true,
Tag: inbound.Tag,
Up: 100,
Down: 200,
}},
[]*xray.ClientTraffic{{
Email: "alice@example.com",
Up: 10,
Down: 20,
}},
)
if err != nil {
t.Fatalf("AddTraffic: %v", err)
}
var storedInbound model.Inbound
if err := db.First(&storedInbound, inbound.Id).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
if storedInbound.Up != 200 || storedInbound.Down != 400 {
t.Fatalf("inbound traffic = up %d/down %d, want up 200/down 400", storedInbound.Up, storedInbound.Down)
}
var storedClient xray.ClientTraffic
if err := db.Where("email = ?", "alice@example.com").First(&storedClient).Error; err != nil {
t.Fatalf("load client traffic: %v", err)
}
if storedClient.Up != 20 || storedClient.Down != 40 {
t.Fatalf("client traffic = up %d/down %d, want up 20/down 40", storedClient.Up, storedClient.Down)
}
}
func TestAddTrafficSaturatesMultipliedInboundCounters(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
inbound := model.Inbound{
UserId: 1,
Tag: "inbound-overflow",
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"22222222-2222-2222-2222-222222222222","email":"bob@example.com","enable":true}],"decryption":"none"}`,
TrafficMultiplier: 2,
Up: math.MaxInt64 - 5,
Down: math.MaxInt64 - 5,
Enable: true,
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: inbound.Id,
Email: "bob@example.com",
Enable: true,
Up: math.MaxInt64 - 5,
Down: math.MaxInt64 - 5,
}).Error; err != nil {
t.Fatalf("create client traffic: %v", err)
}
s := InboundService{}
_, _, err := s.AddTraffic(
[]*xray.Traffic{{
IsInbound: true,
Tag: inbound.Tag,
Up: 10,
Down: 10,
}},
[]*xray.ClientTraffic{{
Email: "bob@example.com",
Up: 10,
Down: 10,
}},
)
if err != nil {
t.Fatalf("AddTraffic: %v", err)
}
var storedInbound model.Inbound
if err := db.First(&storedInbound, inbound.Id).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
if storedInbound.Up != math.MaxInt64 || storedInbound.Down != math.MaxInt64 {
t.Fatalf("inbound traffic = up %d/down %d, want saturated MaxInt64", storedInbound.Up, storedInbound.Down)
}
var storedClient xray.ClientTraffic
if err := db.Where("email = ?", "bob@example.com").First(&storedClient).Error; err != nil {
t.Fatalf("load client traffic: %v", err)
}
if storedClient.Up != math.MaxInt64 || storedClient.Down != math.MaxInt64 {
t.Fatalf("client traffic = up %d/down %d, want saturated MaxInt64", storedClient.Up, storedClient.Down)
}
}

View file

@ -305,6 +305,7 @@
"monitorDesc": "سيبها فاضية لو عايز تستمع على كل الـ IPs", "monitorDesc": "سيبها فاضية لو عايز تستمع على كل الـ IPs",
"meansNoLimit": "= غير محدود. (الوحدة: جيجابايت)", "meansNoLimit": "= غير محدود. (الوحدة: جيجابايت)",
"totalFlow": "إجمالي التدفق", "totalFlow": "إجمالي التدفق",
"trafficMultiplier": "مضاعف حركة المرور",
"leaveBlankToNeverExpire": "سيبها فاضية عشان ماتنتهيش", "leaveBlankToNeverExpire": "سيبها فاضية عشان ماتنتهيش",
"noRecommendKeepDefault": "ننصح باستخدام الافتراضي", "noRecommendKeepDefault": "ننصح باستخدام الافتراضي",
"certificatePath": "مسار الملف", "certificatePath": "مسار الملف",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Leave blank to listen on all IPs", "monitorDesc": "Leave blank to listen on all IPs",
"meansNoLimit": "= Unlimited. (unit: GB)", "meansNoLimit": "= Unlimited. (unit: GB)",
"totalFlow": "Total Flow", "totalFlow": "Total Flow",
"trafficMultiplier": "Traffic Multiplier",
"leaveBlankToNeverExpire": "Leave blank to never expire", "leaveBlankToNeverExpire": "Leave blank to never expire",
"noRecommendKeepDefault": "It is recommended to keep the default", "noRecommendKeepDefault": "It is recommended to keep the default",
"certificatePath": "File Path", "certificatePath": "File Path",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Dejar en blanco por defecto", "monitorDesc": "Dejar en blanco por defecto",
"meansNoLimit": " = illimitata. (unidad: GB)", "meansNoLimit": " = illimitata. (unidad: GB)",
"totalFlow": "Flujo Total", "totalFlow": "Flujo Total",
"trafficMultiplier": "Multiplicador de Tráfico",
"leaveBlankToNeverExpire": "Dejar en Blanco para Nunca Expirar", "leaveBlankToNeverExpire": "Dejar en Blanco para Nunca Expirar",
"noRecommendKeepDefault": "No hay requisitos especiales para mantener la configuración predeterminada", "noRecommendKeepDefault": "No hay requisitos especiales para mantener la configuración predeterminada",
"certificatePath": "Ruta Cert", "certificatePath": "Ruta Cert",

View file

@ -305,6 +305,7 @@
"monitorDesc": "به‌طور پیش‌فرض خالی‌بگذارید", "monitorDesc": "به‌طور پیش‌فرض خالی‌بگذارید",
"meansNoLimit": "0 = واحد: گیگابایت) نامحدود)", "meansNoLimit": "0 = واحد: گیگابایت) نامحدود)",
"totalFlow": "ترافیک کل", "totalFlow": "ترافیک کل",
"trafficMultiplier": "ضریب ترافیک",
"leaveBlankToNeverExpire": "برای منقضی‌نشدن خالی‌بگذارید", "leaveBlankToNeverExpire": "برای منقضی‌نشدن خالی‌بگذارید",
"noRecommendKeepDefault": "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود", "noRecommendKeepDefault": "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود",
"certificatePath": "مسیر فایل", "certificatePath": "مسیر فایل",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Biarkan kosong untuk mendengarkan semua IP", "monitorDesc": "Biarkan kosong untuk mendengarkan semua IP",
"meansNoLimit": "= Unlimited. (unit: GB)", "meansNoLimit": "= Unlimited. (unit: GB)",
"totalFlow": "Total Aliran", "totalFlow": "Total Aliran",
"trafficMultiplier": "Pengali Lalu Lintas",
"leaveBlankToNeverExpire": "Biarkan kosong untuk tidak pernah kedaluwarsa", "leaveBlankToNeverExpire": "Biarkan kosong untuk tidak pernah kedaluwarsa",
"noRecommendKeepDefault": "Disarankan untuk tetap menggunakan pengaturan default", "noRecommendKeepDefault": "Disarankan untuk tetap menggunakan pengaturan default",
"certificatePath": "Path Berkas", "certificatePath": "Path Berkas",

View file

@ -305,6 +305,7 @@
"monitorDesc": "空白にするとすべてのIPを監視", "monitorDesc": "空白にするとすべてのIPを監視",
"meansNoLimit": "= 無制限単位GB", "meansNoLimit": "= 無制限単位GB",
"totalFlow": "総トラフィック", "totalFlow": "総トラフィック",
"trafficMultiplier": "トラフィック倍率",
"leaveBlankToNeverExpire": "空白にすると期限なし", "leaveBlankToNeverExpire": "空白にすると期限なし",
"noRecommendKeepDefault": "デフォルト値を保持することをお勧めします", "noRecommendKeepDefault": "デフォルト値を保持することをお勧めします",
"certificatePath": "ファイルパス", "certificatePath": "ファイルパス",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Deixe em branco para ouvir todos os IPs", "monitorDesc": "Deixe em branco para ouvir todos os IPs",
"meansNoLimit": "= Ilimitado. (unidade: GB)", "meansNoLimit": "= Ilimitado. (unidade: GB)",
"totalFlow": "Fluxo Total", "totalFlow": "Fluxo Total",
"trafficMultiplier": "Multiplicador de Tráfego",
"leaveBlankToNeverExpire": "Deixe em branco para nunca expirar", "leaveBlankToNeverExpire": "Deixe em branco para nunca expirar",
"noRecommendKeepDefault": "Recomenda-se manter o padrão", "noRecommendKeepDefault": "Recomenda-se manter o padrão",
"certificatePath": "Caminho", "certificatePath": "Caminho",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Оставьте пустым для прослушивания всех IP-адресов", "monitorDesc": "Оставьте пустым для прослушивания всех IP-адресов",
"meansNoLimit": "= Без ограничений (значение: ГБ)", "meansNoLimit": "= Без ограничений (значение: ГБ)",
"totalFlow": "Общий расход", "totalFlow": "Общий расход",
"trafficMultiplier": "Множитель трафика",
"leaveBlankToNeverExpire": "Оставьте пустым, чтобы было бесконечным", "leaveBlankToNeverExpire": "Оставьте пустым, чтобы было бесконечным",
"noRecommendKeepDefault": "Рекомендуется оставить настройки по умолчанию", "noRecommendKeepDefault": "Рекомендуется оставить настройки по умолчанию",
"certificatePath": "Путь к сертификату", "certificatePath": "Путь к сертификату",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Tüm IP'leri dinlemek için boş bırakın", "monitorDesc": "Tüm IP'leri dinlemek için boş bırakın",
"meansNoLimit": "= Sınırsız. (birim: GB)", "meansNoLimit": "= Sınırsız. (birim: GB)",
"totalFlow": "Toplam Akış", "totalFlow": "Toplam Akış",
"trafficMultiplier": "Trafik Çarpanı",
"leaveBlankToNeverExpire": "Hiçbir zaman sona ermemesi için boş bırakın", "leaveBlankToNeverExpire": "Hiçbir zaman sona ermemesi için boş bırakın",
"noRecommendKeepDefault": "Varsayılanı korumanız önerilir", "noRecommendKeepDefault": "Varsayılanı korumanız önerilir",
"certificatePath": "Dosya Yolu", "certificatePath": "Dosya Yolu",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Залиште порожнім, щоб слухати всі IP-адреси", "monitorDesc": "Залиште порожнім, щоб слухати всі IP-адреси",
"meansNoLimit": "= Необмежено. (одиниця: ГБ)", "meansNoLimit": "= Необмежено. (одиниця: ГБ)",
"totalFlow": "Загальна витрата", "totalFlow": "Загальна витрата",
"trafficMultiplier": "Множник трафіку",
"leaveBlankToNeverExpire": "Залиште порожнім, щоб ніколи не закінчувався", "leaveBlankToNeverExpire": "Залиште порожнім, щоб ніколи не закінчувався",
"noRecommendKeepDefault": "Рекомендується зберегти значення за замовчуванням", "noRecommendKeepDefault": "Рекомендується зберегти значення за замовчуванням",
"certificatePath": "Шлях до файлу", "certificatePath": "Шлях до файлу",

View file

@ -305,6 +305,7 @@
"monitorDesc": "Mặc định để trống", "monitorDesc": "Mặc định để trống",
"meansNoLimit": "= Không giới hạn (đơn vị: GB)", "meansNoLimit": "= Không giới hạn (đơn vị: GB)",
"totalFlow": "Tổng lưu lượng", "totalFlow": "Tổng lưu lượng",
"trafficMultiplier": "Hệ số lưu lượng",
"leaveBlankToNeverExpire": "Để trống để không bao giờ hết hạn", "leaveBlankToNeverExpire": "Để trống để không bao giờ hết hạn",
"noRecommendKeepDefault": "Không yêu cầu đặc biệt để giữ nguyên cài đặt mặc định", "noRecommendKeepDefault": "Không yêu cầu đặc biệt để giữ nguyên cài đặt mặc định",
"certificatePath": "Đường dẫn tập", "certificatePath": "Đường dẫn tập",

View file

@ -305,6 +305,7 @@
"monitorDesc": "留空表示监听所有 IP", "monitorDesc": "留空表示监听所有 IP",
"meansNoLimit": "= 无限制单位GB)", "meansNoLimit": "= 无限制单位GB)",
"totalFlow": "总流量", "totalFlow": "总流量",
"trafficMultiplier": "流量倍率",
"leaveBlankToNeverExpire": "留空表示永不过期", "leaveBlankToNeverExpire": "留空表示永不过期",
"noRecommendKeepDefault": "建议保留默认值", "noRecommendKeepDefault": "建议保留默认值",
"certificatePath": "文件路径", "certificatePath": "文件路径",

View file

@ -305,6 +305,7 @@
"monitorDesc": "留空表示監聽所有 IP", "monitorDesc": "留空表示監聽所有 IP",
"meansNoLimit": "= 無限制單位GB)", "meansNoLimit": "= 無限制單位GB)",
"totalFlow": "總流量", "totalFlow": "總流量",
"trafficMultiplier": "流量倍率",
"leaveBlankToNeverExpire": "留空表示永不過期", "leaveBlankToNeverExpire": "留空表示永不過期",
"noRecommendKeepDefault": "建議保留預設值", "noRecommendKeepDefault": "建議保留預設值",
"certificatePath": "檔案路徑", "certificatePath": "檔案路徑",