Files
l-ui/internal/web/controller/probe_meta.go
T
Muse 93c1620c15 v221 ⑥ probe traffic: binary GB convention + one-shot calibration
Unit unification: GB entries converted at 1e9 while every byte
formatter is binary (1024^3), so a 3000 GB limit rendered 2.73 TB
instead of 2.93 TB. calBytes and the form's limit conversion now
use 1024^3. Stored traffic_limits (bytes, typed x1e9) are rescaled
x1.073741824 exactly once at store open, guarded by the
probe_setting mark gb_1024_migrated_v1; calibration values are
stored in GB (no rescale — only their conversion changed) and
price/sort/reset-day are unitless.

One-shot calibration: the form's cal fields are deltas to apply
now. probe.MergeCalDelta folds a non-zero submitted value into the
stored standing correction and keeps the standing value on a
zero/absent submission; putMeta merges against the stored row
(SaveMeta/UpsertMeta keep plain replace — the notify sweep relies
on it). GET no longer echoes the standing correction into the form,
and the frontend keeps the cal inputs as local state that starts
empty and clears on successful save, so a later unrelated save
resubmits nothing.

Tests: calBytes(3000)=3000x2^30; migration 3.0e12 -> 3000x2^30 with
no second rescale on reopen; one-shot flow (save delta, save
unrelated with zero cal -> calibrated traffic unmoved, second delta
accumulates once). Existing TestTrafficCalibrationAppliedAndFloored
expectation updated to the binary figure. No vitest covers
ProbeMetaSection (disclosed in the report).
2026-10-23 15:02:11 +00:00

296 lines
8.8 KiB
Go

package controller
import (
"fmt"
"math"
"net"
"strconv"
"strings"
"time"
"unicode"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/gin-gonic/gin"
)
// Item 7 B3: probe metadata editing (minimal set). The fields are the
// operator-owned display/plan data of probe_meta (ITEM7-SCOPE §5.2),
// edited from the node page's agent edit dialog and saved through the
// panel-authenticated agent admin group — effective immediately (the
// snapshot cache is invalidated; no restart anywhere). Validation
// mirrors the hub's patch_error chain field for field.
type ProbeMetaController struct{}
func NewProbeMetaController(g *gin.RouterGroup) *ProbeMetaController {
pc := &ProbeMetaController{}
g.GET("/probe-meta/:nodeId", pc.getMeta)
g.PUT("/probe-meta/:nodeId", pc.putMeta)
return pc
}
// probeMetaForm is the wire shape of the edit form: the full meta row,
// replaced wholesale on save.
type probeMetaForm struct {
Price float64 `json:"price"`
Currency string `json:"currency"`
BillingCycle string `json:"billingCycle"`
ExpiresAt *string `json:"expiresAt"`
TrafficLimit int64 `json:"trafficLimit"`
TrafficMode string `json:"trafficMode"`
TrafficResetDay int64 `json:"trafficResetDay"`
Public bool `json:"public"`
Sort int64 `json:"sort"`
Group string `json:"group"`
PublicRemark string `json:"publicRemark"`
Notify bool `json:"notify"`
CountryPin string `json:"countryPin"`
IPv4Pin string `json:"ipv4Pin"`
IPv6Pin string `json:"ipv6Pin"`
// V2.0.1: traffic calibration (GB, either sign) and the counted
// interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
// fields are ONE-SHOT deltas: a non-zero value is folded into the
// stored standing correction on save (see probe.MergeCalDelta);
// zero/absent applies nothing.
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
StatIface string `json:"statIface"`
}
func metaToForm(m probe.Meta) probeMetaForm {
return probeMetaForm{
Price: m.Price,
Currency: m.Currency,
BillingCycle: m.BillingCycle,
ExpiresAt: m.ExpiresAt,
TrafficLimit: m.TrafficLimit,
TrafficMode: m.TrafficMode,
TrafficResetDay: m.TrafficResetDay,
Public: m.Public,
Sort: m.Sort,
Group: m.GroupName,
PublicRemark: m.PublicRemark,
Notify: m.Notify,
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
// boxes always start empty; the standing correction folded
// into the stored row is never echoed back into them.
TrafficCalUpGB: 0,
TrafficCalDownGB: 0,
StatIface: m.StatIface,
}
}
func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
return probe.Meta{
AgentID: agentID,
Price: f.Price,
Currency: f.Currency,
BillingCycle: f.BillingCycle,
ExpiresAt: f.ExpiresAt,
TrafficLimit: f.TrafficLimit,
TrafficMode: f.TrafficMode,
TrafficResetDay: f.TrafficResetDay,
Public: f.Public,
Sort: f.Sort,
GroupName: f.Group,
PublicRemark: f.PublicRemark,
Notify: f.Notify,
CountryPin: f.CountryPin,
IPv4Pin: f.IPv4Pin,
IPv6Pin: f.IPv6Pin,
TrafficCalUpGB: f.TrafficCalUpGB,
TrafficCalDownGB: f.TrafficCalDownGB,
StatIface: f.StatIface,
}
}
// agentForNode resolves the agent bound to a panel node row.
func agentForNode(c *gin.Context) (model.AgentNode, bool) {
nodeID, err := strconv.Atoi(c.Param("nodeId"))
if err != nil {
return model.AgentNode{}, false
}
var agent model.AgentNode
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
return model.AgentNode{}, false
}
return agent, true
}
func (pc *ProbeMetaController) getMeta(c *gin.Context) {
agent, ok := agentForNode(c)
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
meta, found, err := probe.MetaFor(int64(agent.Id))
if err != nil {
jsonMsg(c, "load probe meta failed", err)
return
}
if !found {
meta = probe.DefaultMeta(int64(agent.Id))
}
jsonObj(c, metaToForm(meta), nil)
}
func (pc *ProbeMetaController) putMeta(c *gin.Context) {
agent, ok := agentForNode(c)
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
var form probeMetaForm
if err := c.ShouldBindJSON(&form); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if msg := validateProbeMetaForm(&form); msg != "" {
jsonMsg(c, msg, errRequest)
return
}
// Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
// into the stored standing correction; all other fields replace.
meta := form.toMeta(int64(agent.Id))
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
} else if found {
meta = probe.MergeCalDelta(existing, meta)
}
if err := probe.SaveMeta(meta); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
}
service.InvalidateProbeSnapshot()
jsonObj(c, gin.H{"message": "ok"}, nil)
}
// ---- hub patch_error ports ----
func hasControl(s string) bool {
for _, r := range s {
if unicode.IsControl(r) {
return true
}
}
return false
}
var namedCycles = map[string]int64{
"monthly": 1, "quarterly": 3, "semiannual": 6,
"yearly": 12, "biennial": 24, "triennial": 36,
}
func cycleMonths(cycle string) (int64, bool) {
if m, ok := namedCycles[cycle]; ok {
return m, true
}
if strings.HasSuffix(cycle, "m") {
if n, err := strconv.ParseInt(strings.TrimSuffix(cycle, "m"), 10, 64); err == nil && n >= 1 && n <= 1200 {
return n, true
}
}
return 0, false
}
func cycleName(months int64) string {
for name, m := range namedCycles {
if m == months {
return name
}
}
return fmt.Sprintf("%dm", months)
}
// validateProbeMetaForm normalizes the form in place (hub patch_error
// order: group, public remark, limits, billing, pins) and returns the
// first refusal's message, "" when the form may be stored.
func validateProbeMetaForm(f *probeMetaForm) string {
f.Group = strings.TrimSpace(f.Group)
if len([]rune(f.Group)) > 13 || hasControl(f.Group) {
return "分组名最多 13 个字,不能含控制字符"
}
f.PublicRemark = strings.TrimSpace(f.PublicRemark)
if len([]rune(f.PublicRemark)) > 100 || hasControl(f.PublicRemark) {
return "公开备注最多 100 个字,不能含控制字符"
}
if f.TrafficResetDay < 1 || f.TrafficResetDay > 31 {
return "流量重置日要在 1 到 31 之间"
}
if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 {
return "价格和流量上限不能是负数"
}
if math.IsNaN(f.TrafficCalUpGB) || math.IsInf(f.TrafficCalUpGB, 0) ||
math.IsNaN(f.TrafficCalDownGB) || math.IsInf(f.TrafficCalDownGB, 0) {
return "流量校正要填有限的数字(GB,可正可负)"
}
f.StatIface = strings.TrimSpace(f.StatIface)
if len(f.StatIface) > 32 || hasControl(f.StatIface) || strings.ContainsAny(f.StatIface, " \t/") {
return "统计网卡填网卡名(如 eth0),留空则自动甄别"
}
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
if len(f.Currency) != 3 || !isUpperASCII(f.Currency) {
return "货币要填三个字母的代码,如 USD、CNY、HKD"
}
if f.BillingCycle != "once" {
months, ok := cycleMonths(f.BillingCycle)
if !ok {
return "付款周期要是整月,在 1 个月到 100 年之间"
}
f.BillingCycle = cycleName(months)
}
f.CountryPin = strings.ToUpper(strings.TrimSpace(f.CountryPin))
if f.CountryPin != "" && (len(f.CountryPin) != 2 || !isUpperASCII(f.CountryPin)) {
return "国家要填两个字母的代码,留空则自动识别"
}
for _, pin := range []struct {
val *string
v6 bool
}{
{&f.IPv4Pin, false},
{&f.IPv6Pin, true},
} {
typed := strings.TrimSpace(*pin.val)
if typed == "" {
*pin.val = ""
continue
}
ip := net.ParseIP(typed)
if ip == nil || (ip.To4() == nil) != pin.v6 {
if pin.v6 {
return "IPv6 要填一个 IPv6 地址,或者留空"
}
return "IPv4 要填一个 IPv4 地址,或者留空"
}
*pin.val = ip.String()
}
if f.ExpiresAt != nil {
v := strings.TrimSpace(*f.ExpiresAt)
if v == "" {
f.ExpiresAt = nil
} else if _, err := time.ParseInLocation("2006-01-02", v, time.Local); err != nil {
return "到期日期格式应为 YYYY-MM-DD"
} else {
f.ExpiresAt = &v
}
}
return ""
}
func isUpperASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < 'A' || s[i] > 'Z' {
return false
}
}
return true
}