mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
fix(sub): {{INBOUND}} = inbound remark, fix {{TRAFFIC_LEFT}} across inbounds (#5443)
Issue 1: the host endpoint remark no longer substitutes the inbound remark
as the config name. {{INBOUND}} always resolves to the inbound's own remark
and {{HOST}} to the host remark, so both can be shown side by side instead
of the host name appearing twice. configName() drops hostRemark entirely;
token help text updated in all locales.
Issue 2: client_traffics.email is globally unique, so a client shared across
several inbounds of one subscription has a single traffic row owned by one
inbound. statsForClient only searched the current inbound's preloaded
ClientStats, missing on every other inbound's link and falling back to
Up=Down=0 -- so {{TRAFFIC_LEFT}} printed the full quota. Build a per-request
email->stats map from all the subscription's inbounds (no extra queries) and
fall back to it.
This commit is contained in:
@@ -149,7 +149,8 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
} else {
|
||||
var result strings.Builder
|
||||
for _, sub := range subs {
|
||||
result.WriteString(sub + "\n")
|
||||
result.WriteString(sub)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
|
||||
|
||||
+18
-12
@@ -15,8 +15,9 @@ import (
|
||||
// remarkContext carries the per-client data a remark template can interpolate.
|
||||
// stats holds the live traffic record when one exists; when it doesn't, the
|
||||
// caller synthesizes a minimal one from the client so expiry/total/status tokens
|
||||
// still resolve. hostRemark is the host endpoint's own remark: it takes priority
|
||||
// over the inbound's remark as the config name and backs the {{HOST}} token.
|
||||
// still resolve. hostRemark is the host endpoint's own remark; it backs the
|
||||
// {{HOST}} token only — it never substitutes the inbound's remark as the config
|
||||
// name (use {{INBOUND}} and {{HOST}} side by side to show both).
|
||||
type remarkContext struct {
|
||||
client model.Client
|
||||
stats xray.ClientTraffic
|
||||
@@ -24,12 +25,9 @@ type remarkContext struct {
|
||||
hostRemark string
|
||||
}
|
||||
|
||||
// configName is the display name for a link: the host endpoint's own remark when
|
||||
// it has one, otherwise the inbound's remark.
|
||||
// configName is the display name for a link: always the inbound's own remark.
|
||||
// The host endpoint's remark is surfaced only through the {{HOST}} token.
|
||||
func (ctx remarkContext) configName() string {
|
||||
if ctx.hostRemark != "" {
|
||||
return ctx.hostRemark
|
||||
}
|
||||
if ctx.inbound != nil {
|
||||
return ctx.inbound.Remark
|
||||
}
|
||||
@@ -227,6 +225,14 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
|
||||
if stats, ok := s.findClientStats(inbound, client.Email); ok {
|
||||
return stats
|
||||
}
|
||||
// client_traffics.email is globally unique, so a client shared across several
|
||||
// inbounds of one subscription has a single traffic row owned by exactly one
|
||||
// inbound. On every other inbound's link findClientStats misses; fall back to
|
||||
// the per-request map built from all the subscription's inbounds so
|
||||
// {{TRAFFIC_*}} reflect real usage instead of the full quota (#5443).
|
||||
if stats, ok := s.statsByEmail[client.Email]; ok {
|
||||
return stats
|
||||
}
|
||||
return xray.ClientTraffic{
|
||||
Enable: client.Enable,
|
||||
ExpiryTime: client.ExpiryTime,
|
||||
@@ -292,8 +298,8 @@ func (s *SubService) effectiveTemplate(email string) string {
|
||||
}
|
||||
|
||||
// genTemplatedRemark expands the remark template for one client. hostRemark is
|
||||
// the host endpoint's remark (empty for a plain inbound); it takes priority over
|
||||
// the inbound remark for the config name and backs the {{HOST}} token.
|
||||
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
|
||||
// token only and never substitutes the inbound remark as the config name.
|
||||
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
|
||||
ctx := remarkContext{
|
||||
client: client,
|
||||
@@ -311,9 +317,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
|
||||
}
|
||||
|
||||
// genHostRemark builds one host endpoint's remark for a specific client. The
|
||||
// config name is the host endpoint's own remark when set, otherwise the inbound's
|
||||
// remark. In the subscription body the rest of the remark template still applies;
|
||||
// displays show just the config name.
|
||||
// config name is always the inbound's own remark; the host's remark is surfaced
|
||||
// only through the {{HOST}} token. In the subscription body the rest of the
|
||||
// remark template still applies; displays show just the config name.
|
||||
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
|
||||
if !s.subscriptionBody {
|
||||
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
|
||||
|
||||
@@ -165,30 +165,30 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
|
||||
return s, inbound, client
|
||||
}
|
||||
|
||||
// The config name prefers the host endpoint's own remark; the inbound's remark is
|
||||
// the fallback, used only when the host has none.
|
||||
func TestGenHostRemark_ConfigNameHostWins(t *testing.T) {
|
||||
// The config name is always the inbound's own remark; the host endpoint's remark
|
||||
// never substitutes it (it is reachable only through {{HOST}}).
|
||||
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("") // no template → config name only
|
||||
if got := s.genHostRemark(inbound, client, "Relay"); got != "Relay" {
|
||||
t.Fatalf("genHostRemark = %q, want %q (host remark wins)", got, "Relay")
|
||||
if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
|
||||
t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
|
||||
}
|
||||
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q (inbound fallback)", got, "DE")
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
|
||||
}
|
||||
}
|
||||
|
||||
// In the body the template applies: {{INBOUND}} is the config name (host remark
|
||||
// first, inbound fallback) and {{HOST}} is always the host's own remark.
|
||||
// In the body the template applies: {{INBOUND}} is always the inbound's remark
|
||||
// and {{HOST}} the host's own remark, so the two can be shown side by side.
|
||||
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
|
||||
// Host remark set → {{INBOUND}} resolves to it (host wins over the inbound).
|
||||
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN | 80.00GB | 10d" {
|
||||
t.Fatalf("global template (host wins) = %q", got)
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
|
||||
t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
|
||||
}
|
||||
// No host remark → {{INBOUND}} falls back to the inbound's own remark.
|
||||
s2, inbound2, client2 := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}}")
|
||||
if got := s2.genHostRemark(inbound2, client2, ""); got != "DE | 80.00GB" {
|
||||
t.Fatalf("global template (inbound fallback) = %q", got)
|
||||
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
|
||||
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
|
||||
if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
|
||||
t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
|
||||
}
|
||||
// {{HOST}} is the host's own remark even when the inbound has one of its own.
|
||||
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
|
||||
@@ -239,12 +239,12 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
|
||||
func TestRemarkInDisplayContext(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||
s.subscriptionBody = false
|
||||
// A host link in a display shows only the config name — host remark wins, with
|
||||
// no per-client email or usage info.
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN" {
|
||||
t.Fatalf("display host link = %q, want config name %q (host wins)", got, "CDN")
|
||||
// A host link in a display shows only the config name — the inbound's remark,
|
||||
// with no per-client email or usage info and the host remark ignored.
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
|
||||
t.Fatalf("display host link = %q, want config name %q", got, "DE")
|
||||
}
|
||||
// With no host remark, the config name is the inbound's own remark.
|
||||
// With no host remark, the config name is likewise the inbound's own remark.
|
||||
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
|
||||
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
|
||||
}
|
||||
@@ -270,6 +270,26 @@ func TestNameOnlyTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// statsForClient resolves usage from the per-request statsByEmail map when the
|
||||
// link's own inbound doesn't carry the client's (globally unique) traffic row —
|
||||
// the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
|
||||
func TestStatsForClient_CrossInboundFallback(t *testing.T) {
|
||||
s := &SubService{
|
||||
statsByEmail: map[string]xray.ClientTraffic{
|
||||
"john@example.com": {Email: "john@example.com", Total: 100 * gb, Up: 15 * gb, Down: 5 * gb},
|
||||
},
|
||||
}
|
||||
// Inbound B carries no ClientStats for john (his row is owned by inbound A).
|
||||
inboundB := &model.Inbound{Remark: "B"}
|
||||
st := s.statsForClient(inboundB, model.Client{Email: "john@example.com"})
|
||||
if used := st.Up + st.Down; used != 20*gb {
|
||||
t.Fatalf("statsForClient used = %d, want %d (cross-inbound fallback)", used, 20*gb)
|
||||
}
|
||||
if got := remarkVarValue("TRAFFIC_LEFT", remarkContext{stats: st}); got != "80.00GB" {
|
||||
t.Fatalf("TRAFFIC_LEFT = %q, want 80.00GB (remaining, not total)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two clients through the same global template get distinct, per-client remarks.
|
||||
func TestGenHostRemark_PerClient(t *testing.T) {
|
||||
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
|
||||
|
||||
@@ -47,6 +47,12 @@ type SubService struct {
|
||||
// inbound whose NodeID is set. Keeps the per-link host derivation
|
||||
// O(1) instead of O(N) DB hits.
|
||||
nodesByID map[int]*model.Node
|
||||
// statsByEmail maps a client email to its traffic row across ALL inbounds
|
||||
// loaded for the request. client_traffics.email is globally unique, so this
|
||||
// lets statsForClient resolve usage for a client even on an inbound that
|
||||
// doesn't own its row (multi-inbound subscriptions). Filled in
|
||||
// getInboundsBySubId; reset per request in PrepareForRequest.
|
||||
statsByEmail map[string]xray.ClientTraffic
|
||||
}
|
||||
|
||||
// NewSubService creates a new subscription service with the given configuration.
|
||||
@@ -78,6 +84,7 @@ func (s *SubService) PrepareForRequest(host string) {
|
||||
}
|
||||
s.address = host
|
||||
s.usageShown = map[string]bool{}
|
||||
s.statsByEmail = map[string]xray.ClientTraffic{}
|
||||
s.loadNodes()
|
||||
s.loadRemarkSettings()
|
||||
}
|
||||
@@ -335,9 +342,24 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexStatsByEmail(inbounds)
|
||||
return inbounds, nil
|
||||
}
|
||||
|
||||
// indexStatsByEmail records every loaded inbound's client traffic rows keyed by
|
||||
// email so statsForClient can resolve a client's usage even on an inbound that
|
||||
// doesn't own its (globally unique) client_traffics row. See statsByEmail.
|
||||
func (s *SubService) indexStatsByEmail(inbounds []*model.Inbound) {
|
||||
if s.statsByEmail == nil {
|
||||
s.statsByEmail = map[string]xray.ClientTraffic{}
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
for _, st := range inbound.ClientStats {
|
||||
s.statsByEmail[st.Email] = st
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// projectThroughFallbackMaster mutates the inbound in place so its
|
||||
// Listen/Port/StreamSettings reflect the externally reachable master
|
||||
// when applicable. Covers both fallback mechanisms:
|
||||
|
||||
Reference in New Issue
Block a user