diff --git a/go-backend/internal/http/handler/flow_policy.go b/go-backend/internal/http/handler/flow_policy.go index 9691d17..ef2bad7 100644 --- a/go-backend/internal/http/handler/flow_policy.go +++ b/go-backend/internal/http/handler/flow_policy.go @@ -44,6 +44,11 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) { if ok { inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U) _ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow) + if forward, err := h.getForwardRecord(forwardID); err == nil && forward != nil { + if quota, quotaErr := h.repo.AddTunnelQuotaUsage(forward.TunnelID, inFlow+outFlow, time.Now()); quotaErr == nil { + h.enforceTunnelQuotaIfNeeded(forward.TunnelID, quota) + } + } h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item) if userTunnelID > 0 { @@ -356,6 +361,9 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n if flowLimit < current { return errors.New("流量已超额,禁止开启转发") } + if err := h.ensureTunnelForwardAllowedByQuota(tunnelID, now); err != nil { + return err + } userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID) if err != nil { diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 2e4cf37..3146caa 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -132,6 +132,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate) mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet) mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate) + mux.HandleFunc("/api/v1/tunnel/quota/reset", h.tunnelQuotaReset) mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete) mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose) mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream) diff --git a/go-backend/internal/http/handler/jobs.go b/go-backend/internal/http/handler/jobs.go index 2096b39..0413206 100644 --- a/go-backend/internal/http/handler/jobs.go +++ b/go-backend/internal/http/handler/jobs.go @@ -136,6 +136,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) { } h.resetMonthlyFlow(now) + h.resetTunnelQuotaWindows(now) h.disableExpiredUsers(now.UnixMilli()) h.disableExpiredUserTunnels(now.UnixMilli()) } diff --git a/go-backend/internal/http/handler/jobs_test.go b/go-backend/internal/http/handler/jobs_test.go index 98a2315..ca2c4f9 100644 --- a/go-backend/internal/http/handler/jobs_test.go +++ b/go-backend/internal/http/handler/jobs_test.go @@ -143,3 +143,44 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) { t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus) } } + +func TestRunResetAndExpiryJobResetsTunnelQuotaAndReEnablesTunnel(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db") + r, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + + h := New(r, "secret") + now := time.Date(2026, 3, 12, 0, 0, 5, 0, time.UTC) + nowMs := now.UnixMilli() + + if err := r.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'quota-reset-tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0) + `, nowMs, nowMs).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) + VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?) + `, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil { + t.Fatalf("insert tunnel quota: %v", err) + } + + h.runResetAndExpiryJob(now) + + tunnelStatus := mustQueryInt(t, r, `SELECT status FROM tunnel WHERE id = 1`) + if tunnelStatus != 1 { + t.Fatalf("expected tunnel re-enabled after quota reset, got %d", tunnelStatus) + } + dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM tunnel_quota WHERE tunnel_id = 1`) + if dailyUsed != 0 { + t.Fatalf("expected daily quota usage reset, got %d", dailyUsed) + } + quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM tunnel_quota WHERE tunnel_id = 1`) + if quotaDisabled != 0 { + t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled) + } +} diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 80ac3a0..f256cd3 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -485,6 +485,8 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) { typeVal := asInt(req["type"], 1) flow := asInt64(req["flow"], 1) + dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0) + monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0) status := asInt(req["status"], 1) trafficRatio := asFloat(req["trafficRatio"], 1.0) inIP := asString(req["inIp"]) @@ -580,6 +582,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) { return } tunnelID := tunnel.ID + if err := h.repo.SaveTunnelQuotaConfigTx(tx, tunnelID, dailyQuotaGB, monthlyQuotaGB, now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } runtimeState.TunnelID = tunnelID var federationBindings []repo.FederationTunnelBinding var federationReleaseRefs []federationRuntimeReleaseRef @@ -691,6 +697,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { now := time.Now().UnixMilli() typeVal := asInt(req["type"], 1) + dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0) + monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0) ipPreference := asString(req["ipPreference"]) localDomain := h.federationLocalDomain() @@ -735,6 +743,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + if err := h.repo.SaveTunnelQuotaConfigTx(tx, id, dailyQuotaGB, monthlyQuotaGB, now); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -1299,6 +1311,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { return } if tunnel.Status != 1 { + if reason, quotaErr := h.tunnelQuotaBlockReason(tunnelID, time.Now().UnixMilli()); quotaErr == nil && reason != "" { + response.WriteJSON(w, response.ErrDefault(reason)) + return + } response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法创建转发")) return } @@ -1420,6 +1436,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { return } if tunnel.Status != 1 { + if reason, quotaErr := h.tunnelQuotaBlockReason(tunnelID, time.Now().UnixMilli()); quotaErr == nil && reason != "" { + response.WriteJSON(w, response.ErrDefault(reason)) + return + } response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法更新转发")) return } diff --git a/go-backend/internal/http/handler/tunnel_quota.go b/go-backend/internal/http/handler/tunnel_quota.go new file mode 100644 index 0000000..9773ff0 --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_quota.go @@ -0,0 +1,140 @@ +package handler + +import ( + "errors" + "net/http" + "time" + + "go-backend/internal/http/response" + "go-backend/internal/store/model" + "go-backend/internal/store/repo" +) + +func isTunnelQuotaExceeded(view *model.TunnelQuotaView) bool { + if view == nil { + return false + } + if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*bytesPerGB { + return true + } + if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*bytesPerGB { + return true + } + return false +} + +func (h *Handler) tunnelQuotaBlockReason(tunnelID int64, now int64) (string, error) { + if h == nil || h.repo == nil || tunnelID <= 0 { + return "", nil + } + quota, err := h.repo.GetTunnelQuotaView(tunnelID, time.UnixMilli(now)) + if err != nil || quota == nil { + return "", err + } + if quota.DisabledByQuota == 1 || isTunnelQuotaExceeded(quota) { + return "该隧道流量配额已超额,禁止开启转发", nil + } + return "", nil +} + +func (h *Handler) enforceTunnelQuotaIfNeeded(tunnelID int64, quota *model.TunnelQuotaView) { + if h == nil || h.repo == nil || tunnelID <= 0 || quota == nil { + return + } + if quota.DisabledByQuota == 1 || !isTunnelQuotaExceeded(quota) { + return + } + + forwards, err := h.listForwardsByTunnel(tunnelID) + if err != nil { + return + } + pausedIDs := make([]int64, 0, len(forwards)) + now := time.Now().UnixMilli() + for i := range forwards { + if forwards[i].Status != 1 { + continue + } + if err := h.controlForwardServices(&forwards[i], "PauseService", false); err != nil { + continue + } + if err := h.repo.UpdateForwardStatus(forwards[i].ID, 0, now); err != nil { + continue + } + pausedIDs = append(pausedIDs, forwards[i].ID) + } + _ = h.repo.UpdateTunnelStatus(tunnelID, 0, now) + _ = h.repo.MarkTunnelQuotaDisabled(tunnelID, pausedIDs, now) +} + +func (h *Handler) applyTunnelQuotaRelease(release *repo.TunnelQuotaRelease, now int64) { + if h == nil || h.repo == nil || release == nil || release.TunnelID <= 0 || !release.EnableTunnel { + return + } + _ = h.repo.UpdateTunnelStatus(release.TunnelID, 1, now) + for _, forwardID := range release.ForwardIDs { + forward, err := h.getForwardRecord(forwardID) + if err != nil || forward == nil { + continue + } + if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil { + continue + } + if err := h.controlForwardServices(forward, "ResumeService", false); err != nil { + continue + } + _ = h.repo.UpdateForwardStatus(forwardID, 1, now) + } +} + +func (h *Handler) resetTunnelQuotaWindows(now time.Time) { + if h == nil || h.repo == nil { + return + } + releases, err := h.repo.RollTunnelQuotaWindows(now) + if err != nil { + return + } + nowMs := now.UnixMilli() + for i := range releases { + h.applyTunnelQuotaRelease(&releases[i], nowMs) + } +} + +func (h *Handler) tunnelQuotaReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + var req struct { + TunnelID int64 `json:"tunnelId"` + Scope string `json:"scope"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.TunnelID <= 0 { + response.WriteJSON(w, response.ErrDefault("隧道ID不能为空")) + return + } + release, err := h.repo.ResetTunnelQuotaUsage(req.TunnelID, req.Scope, time.Now()) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + nowMs := time.Now().UnixMilli() + h.applyTunnelQuotaRelease(release, nowMs) + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) ensureTunnelForwardAllowedByQuota(tunnelID int64, now int64) error { + reason, err := h.tunnelQuotaBlockReason(tunnelID, now) + if err != nil { + return err + } + if reason != "" { + return errors.New(reason) + } + return nil +} diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index ae97478..421d302 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -129,6 +129,23 @@ type Tunnel struct { func (Tunnel) TableName() string { return "tunnel" } +type TunnelQuota struct { + TunnelID int64 `gorm:"column:tunnel_id;primaryKey"` + DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"` + MonthlyLimitGB int64 `gorm:"column:monthly_limit_gb;not null;default:0"` + DailyUsedBytes int64 `gorm:"column:daily_used_bytes;not null;default:0"` + MonthlyUsedBytes int64 `gorm:"column:monthly_used_bytes;not null;default:0"` + DayKey int64 `gorm:"column:day_key;not null;default:0"` + MonthKey int64 `gorm:"column:month_key;not null;default:0"` + DisabledByQuota int `gorm:"column:disabled_by_quota;not null;default:0"` + DisabledAt int64 `gorm:"column:disabled_at;not null;default:0"` + PausedForwardIDs string `gorm:"column:paused_forward_ids;type:text;not null;default:''"` + CreatedTime int64 `gorm:"column:created_time;not null"` + UpdatedTime int64 `gorm:"column:updated_time;not null"` +} + +func (TunnelQuota) TableName() string { return "tunnel_quota" } + type ChainTunnel struct { ID int64 `gorm:"primaryKey;autoIncrement"` TunnelID int64 `gorm:"column:tunnel_id;not null"` @@ -366,19 +383,23 @@ type NodeBackup struct { } type TunnelBackup struct { - ID int64 `json:"id"` - Name string `json:"name"` - TrafficRatio float64 `json:"trafficRatio"` - Type int `json:"type"` - Protocol string `json:"protocol"` - Flow int64 `json:"flow"` - CreatedTime int64 `json:"createdTime"` - UpdatedTime int64 `json:"updatedTime"` - Status int `json:"status"` - InIP string `json:"inIp,omitempty"` - Inx int `json:"inx"` - IPPreference string `json:"ipPreference,omitempty"` - ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + TrafficRatio float64 `json:"trafficRatio"` + Type int `json:"type"` + Protocol string `json:"protocol"` + Flow int64 `json:"flow"` + CreatedTime int64 `json:"createdTime"` + UpdatedTime int64 `json:"updatedTime"` + Status int `json:"status"` + InIP string `json:"inIp,omitempty"` + Inx int `json:"inx"` + IPPreference string `json:"ipPreference,omitempty"` + DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"` + MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"` + DisabledByQuota int `json:"disabledByQuota,omitempty"` + QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"` + ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"` } type ChainTunnelBackup struct { @@ -516,6 +537,19 @@ type TunnelRecord struct { TrafficRatio float64 } +type TunnelQuotaView struct { + TunnelID int64 + DailyLimitGB int64 + MonthlyLimitGB int64 + DailyUsedBytes int64 + MonthlyUsedBytes int64 + DayKey int64 + MonthKey int64 + DisabledByQuota int + DisabledAt int64 + PausedForwardIDs string +} + // ForwardPortRecord is a forward port mapping used by control plane. type ForwardPortRecord struct { NodeID int64 diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index d415622..94790b3 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -167,6 +167,7 @@ func autoMigrateAll(db *gorm.DB) error { &model.SpeedLimit{}, &model.StatisticsFlow{}, &model.Tunnel{}, + &model.TunnelQuota{}, &model.ChainTunnel{}, &model.UserTunnel{}, &model.TunnelGroup{}, @@ -957,6 +958,7 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) { tunnelMap := make(map[int64]map[string]interface{}) orderedIDs := make([]int64, 0, len(tunnels)) + tunnelIDs := make([]int64, 0, len(tunnels)) for _, t := range tunnels { tunnelMap[t.ID] = map[string]interface{}{ @@ -970,6 +972,24 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) { "chainNodes": make([][]map[string]interface{}, 0), } orderedIDs = append(orderedIDs, t.ID) + tunnelIDs = append(tunnelIDs, t.ID) + } + + quotaMap, err := r.ListTunnelQuotaViewsByTunnelIDs(tunnelIDs, time.Now()) + if err != nil { + return nil, err + } + for tunnelID, quota := range quotaMap { + item := tunnelMap[tunnelID] + if item == nil || quota == nil { + continue + } + item["dailyQuotaGB"] = quota.DailyLimitGB + item["monthlyQuotaGB"] = quota.MonthlyLimitGB + item["dailyUsedBytes"] = quota.DailyUsedBytes + item["monthlyUsedBytes"] = quota.MonthlyUsedBytes + item["disabledByQuota"] = quota.DisabledByQuota + item["quotaDisabledAt"] = quota.DisabledAt } // Build node IP map @@ -1862,6 +1882,14 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) { if err := r.db.Order("inx ASC, id ASC").Find(&tunnels).Error; err != nil { return nil, err } + quotaIDs := make([]int64, 0, len(tunnels)) + for _, t := range tunnels { + quotaIDs = append(quotaIDs, t.ID) + } + quotaMap, err := r.ListTunnelQuotaViewsByTunnelIDs(quotaIDs, time.Now()) + if err != nil { + return nil, err + } out := make([]model.TunnelBackup, 0, len(tunnels)) for _, t := range tunnels { b := model.TunnelBackup{ @@ -1870,6 +1898,12 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) { CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime, Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference, } + if quota := quotaMap[t.ID]; quota != nil { + b.DailyQuotaGB = quota.DailyLimitGB + b.MonthlyQuotaGB = quota.MonthlyLimitGB + b.DisabledByQuota = quota.DisabledByQuota + b.QuotaDisabledAt = quota.DisabledAt + } if t.InIP.Valid { b.InIP = t.InIP.String } @@ -2243,6 +2277,26 @@ func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, e if err != nil { return count, err } + quotaItem := model.TunnelQuota{ + TunnelID: t.ID, + DailyLimitGB: t.DailyQuotaGB, + MonthlyLimitGB: t.MonthlyQuotaGB, + DisabledByQuota: t.DisabledByQuota, + DisabledAt: t.QuotaDisabledAt, + DayKey: int64(time.Now().Year()*10000 + int(time.Now().Month())*100 + time.Now().Day()), + MonthKey: int64(time.Now().Year()*100 + int(time.Now().Month())), + CreatedTime: now, + UpdatedTime: now, + } + err = tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tunnel_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "daily_limit_gb", "monthly_limit_gb", "disabled_by_quota", "disabled_at", "updated_time", + }), + }).Create("aItem).Error + if err != nil { + return count, err + } for _, ct := range t.ChainTunnels { chainItem := model.ChainTunnel{ ID: ct.ID, diff --git a/go-backend/internal/store/repo/repository_tunnel_quota.go b/go-backend/internal/store/repo/repository_tunnel_quota.go new file mode 100644 index 0000000..c435bc7 --- /dev/null +++ b/go-backend/internal/store/repo/repository_tunnel_quota.go @@ -0,0 +1,391 @@ +package repo + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "go-backend/internal/store/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const tunnelQuotaBytesPerGB int64 = 1024 * 1024 * 1024 + +type TunnelQuotaRelease struct { + TunnelID int64 + ForwardIDs []int64 + EnableTunnel bool +} + +func tunnelQuotaWindowKeys(now time.Time) (int64, int64) { + return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()), int64(now.Year()*100 + int(now.Month())) +} + +func cloneTunnelQuotaView(q model.TunnelQuota) *model.TunnelQuotaView { + return &model.TunnelQuotaView{ + TunnelID: q.TunnelID, + DailyLimitGB: q.DailyLimitGB, + MonthlyLimitGB: q.MonthlyLimitGB, + DailyUsedBytes: q.DailyUsedBytes, + MonthlyUsedBytes: q.MonthlyUsedBytes, + DayKey: q.DayKey, + MonthKey: q.MonthKey, + DisabledByQuota: q.DisabledByQuota, + DisabledAt: q.DisabledAt, + PausedForwardIDs: q.PausedForwardIDs, + } +} + +func normalizeTunnelQuotaView(view *model.TunnelQuotaView, now time.Time) *model.TunnelQuotaView { + if view == nil { + return nil + } + dayKey, monthKey := tunnelQuotaWindowKeys(now) + out := *view + if out.DayKey != dayKey { + out.DayKey = dayKey + out.DailyUsedBytes = 0 + } + if out.MonthKey != monthKey { + out.MonthKey = monthKey + out.MonthlyUsedBytes = 0 + } + return &out +} + +func tunnelQuotaExceeded(view *model.TunnelQuotaView) bool { + if view == nil { + return false + } + if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*tunnelQuotaBytesPerGB { + return true + } + if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*tunnelQuotaBytesPerGB { + return true + } + return false +} + +func parsePausedForwardIDs(raw string) []int64 { + parts := strings.Split(strings.TrimSpace(raw), ",") + out := make([]int64, 0, len(parts)) + seen := make(map[int64]struct{}, len(parts)) + for _, part := range parts { + id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil || id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func joinPausedForwardIDs(ids []int64) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + parts = append(parts, strconv.FormatInt(id, 10)) + } + return strings.Join(parts, ",") +} + +func (r *Repository) loadOrCreateTunnelQuotaTx(tx *gorm.DB, tunnelID int64, now time.Time) (*model.TunnelQuota, error) { + if tx == nil { + return nil, errors.New("database unavailable") + } + dayKey, monthKey := tunnelQuotaWindowKeys(now) + q := &model.TunnelQuota{} + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tunnel_id = ?", tunnelID).First(q).Error + if err == nil { + return q, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + nowMs := now.UnixMilli() + q = &model.TunnelQuota{ + TunnelID: tunnelID, + DayKey: dayKey, + MonthKey: monthKey, + CreatedTime: nowMs, + UpdatedTime: nowMs, + PausedForwardIDs: "", + } + if err := tx.Create(q).Error; err != nil { + return nil, err + } + return q, nil +} + +func applyTunnelQuotaWindowRoll(q *model.TunnelQuota, now time.Time) bool { + if q == nil { + return false + } + changed := false + dayKey, monthKey := tunnelQuotaWindowKeys(now) + if q.DayKey != dayKey { + q.DayKey = dayKey + q.DailyUsedBytes = 0 + changed = true + } + if q.MonthKey != monthKey { + q.MonthKey = monthKey + q.MonthlyUsedBytes = 0 + changed = true + } + return changed +} + +func (r *Repository) SaveTunnelQuotaConfigTx(tx *gorm.DB, tunnelID, dailyLimitGB, monthlyLimitGB int64, now int64) error { + if tx == nil { + return errors.New("database unavailable") + } + if tunnelID <= 0 { + return errors.New("tunnel id is required") + } + if dailyLimitGB < 0 || monthlyLimitGB < 0 { + return errors.New("quota limit cannot be negative") + } + current := time.UnixMilli(now) + q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, current) + if err != nil { + return err + } + updates := map[string]interface{}{ + "daily_limit_gb": dailyLimitGB, + "monthly_limit_gb": monthlyLimitGB, + "updated_time": now, + } + if q.DayKey == 0 || q.MonthKey == 0 { + dayKey, monthKey := tunnelQuotaWindowKeys(current) + updates["day_key"] = dayKey + updates["month_key"] = monthKey + } + return tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(updates).Error +} + +func (r *Repository) ListTunnelQuotaViewsByTunnelIDs(tunnelIDs []int64, now time.Time) (map[int64]*model.TunnelQuotaView, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + out := make(map[int64]*model.TunnelQuotaView) + if len(tunnelIDs) == 0 { + return out, nil + } + var rows []model.TunnelQuota + if err := r.db.Where("tunnel_id IN ?", tunnelIDs).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + out[row.TunnelID] = normalizeTunnelQuotaView(cloneTunnelQuotaView(row), now) + } + return out, nil +} + +func (r *Repository) GetTunnelQuotaView(tunnelID int64, now time.Time) (*model.TunnelQuotaView, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + if tunnelID <= 0 { + return nil, nil + } + var row model.TunnelQuota + err := r.db.Where("tunnel_id = ?", tunnelID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return normalizeTunnelQuotaView(cloneTunnelQuotaView(row), now), nil +} + +func (r *Repository) AddTunnelQuotaUsage(tunnelID int64, usedBytes int64, now time.Time) (*model.TunnelQuotaView, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + if tunnelID <= 0 { + return nil, nil + } + result := &model.TunnelQuotaView{} + err := r.db.Transaction(func(tx *gorm.DB) error { + q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, now) + if err != nil { + return err + } + applyTunnelQuotaWindowRoll(q, now) + if usedBytes > 0 { + q.DailyUsedBytes += usedBytes + q.MonthlyUsedBytes += usedBytes + } + q.UpdatedTime = now.UnixMilli() + if err := tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{ + "daily_used_bytes": q.DailyUsedBytes, + "monthly_used_bytes": q.MonthlyUsedBytes, + "day_key": q.DayKey, + "month_key": q.MonthKey, + "updated_time": q.UpdatedTime, + }).Error; err != nil { + return err + } + *result = *cloneTunnelQuotaView(*q) + return nil + }) + if err != nil { + return nil, err + } + return normalizeTunnelQuotaView(result, now), nil +} + +func (r *Repository) MarkTunnelQuotaDisabled(tunnelID int64, pausedForwardIDs []int64, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + if tunnelID <= 0 { + return errors.New("tunnel id is required") + } + return r.db.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{ + "disabled_by_quota": 1, + "disabled_at": now, + "paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs), + "updated_time": now, + }).Error +} + +func (r *Repository) ResetTunnelQuotaUsage(tunnelID int64, scope string, now time.Time) (*TunnelQuotaRelease, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + if tunnelID <= 0 { + return nil, errors.New("tunnel id is required") + } + scope = strings.TrimSpace(strings.ToLower(scope)) + if scope == "" { + scope = "all" + } + if scope != "daily" && scope != "monthly" && scope != "all" { + return nil, fmt.Errorf("unsupported quota reset scope: %s", scope) + } + var release *TunnelQuotaRelease + err := r.db.Transaction(func(tx *gorm.DB) error { + q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, now) + if err != nil { + return err + } + applyTunnelQuotaWindowRoll(q, now) + switch scope { + case "daily": + q.DailyUsedBytes = 0 + case "monthly": + q.MonthlyUsedBytes = 0 + case "all": + q.DailyUsedBytes = 0 + q.MonthlyUsedBytes = 0 + } + q.UpdatedTime = now.UnixMilli() + release = &TunnelQuotaRelease{TunnelID: tunnelID} + if q.DisabledByQuota == 1 && !tunnelQuotaExceeded(cloneTunnelQuotaView(*q)) { + release.EnableTunnel = true + release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs) + q.DisabledByQuota = 0 + q.DisabledAt = 0 + q.PausedForwardIDs = "" + } + return tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{ + "daily_used_bytes": q.DailyUsedBytes, + "monthly_used_bytes": q.MonthlyUsedBytes, + "day_key": q.DayKey, + "month_key": q.MonthKey, + "disabled_by_quota": q.DisabledByQuota, + "disabled_at": q.DisabledAt, + "paused_forward_ids": q.PausedForwardIDs, + "updated_time": q.UpdatedTime, + }).Error + }) + if err != nil { + return nil, err + } + return release, nil +} + +func (r *Repository) RollTunnelQuotaWindows(now time.Time) ([]TunnelQuotaRelease, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var releases []TunnelQuotaRelease + err := r.db.Transaction(func(tx *gorm.DB) error { + var rows []model.TunnelQuota + if err := tx.Find(&rows).Error; err != nil { + return err + } + nowMs := now.UnixMilli() + for _, row := range rows { + q := row + changed := applyTunnelQuotaWindowRoll(&q, now) + release := TunnelQuotaRelease{TunnelID: q.TunnelID} + if q.DisabledByQuota == 1 && !tunnelQuotaExceeded(cloneTunnelQuotaView(q)) { + release.EnableTunnel = true + release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs) + q.DisabledByQuota = 0 + q.DisabledAt = 0 + q.PausedForwardIDs = "" + changed = true + } + if !changed { + continue + } + q.UpdatedTime = nowMs + if err := tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", q.TunnelID).Updates(map[string]interface{}{ + "daily_used_bytes": q.DailyUsedBytes, + "monthly_used_bytes": q.MonthlyUsedBytes, + "day_key": q.DayKey, + "month_key": q.MonthKey, + "disabled_by_quota": q.DisabledByQuota, + "disabled_at": q.DisabledAt, + "paused_forward_ids": q.PausedForwardIDs, + "updated_time": q.UpdatedTime, + }).Error; err != nil { + return err + } + if release.EnableTunnel { + releases = append(releases, release) + } + } + return nil + }) + if err != nil { + return nil, err + } + return releases, nil +} + +func (r *Repository) UpdateTunnelStatus(tunnelID int64, status int, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + if tunnelID <= 0 { + return errors.New("tunnel id is required") + } + return r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Updates(map[string]interface{}{ + "status": status, + "updated_time": now, + }).Error +} diff --git a/go-backend/tests/contract/tunnel_quota_contract_test.go b/go-backend/tests/contract/tunnel_quota_contract_test.go new file mode 100644 index 0000000..9f999f3 --- /dev/null +++ b/go-backend/tests/contract/tunnel_quota_contract_test.go @@ -0,0 +1,176 @@ +package contract_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go-backend/internal/auth" + "go-backend/internal/http/response" +) + +func TestForwardCreateBlockedWhenTunnelQuotaExceeded(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + now := time.Now().UnixMilli() + + if err := repo.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) + VALUES(2, 'quota_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'quota_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1) + `).Error; err != nil { + t.Fatalf("insert user_tunnel: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) + VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?) + `, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil { + t.Fatalf("insert tunnel_quota: %v", err) + } + + token, err := auth.GenerateToken(2, "quota_user", 1, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(`{"tunnelId":1,"name":"quota-forward","remoteAddr":"1.1.1.1:53"}`)) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected non-zero code when tunnel quota exceeded") + } + if !strings.Contains(out.Msg, "配额") { + t.Fatalf("expected quota error, got %q", out.Msg) + } +} + +func TestForwardResumeBlockedWhenTunnelQuotaExceeded(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + now := time.Now().UnixMilli() + + if err := repo.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) + VALUES(2, 'quota_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'quota_resume_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1) + `).Error; err != nil { + t.Fatalf("insert user_tunnel: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) + VALUES(1, 2, 'quota_resume_user', 'quota_resume_forward', 1, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert forward: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) + VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '1', ?, ?) + `, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil { + t.Fatalf("insert tunnel_quota: %v", err) + } + + token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`)) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected non-zero code when tunnel quota exceeded") + } + if !strings.Contains(out.Msg, "配额") { + t.Fatalf("expected quota error, got %q", out.Msg) + } + status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 1`) + if status != 0 { + t.Fatalf("expected forward to remain paused, got %d", status) + } +} + +func TestTunnelQuotaResetReEnablesTunnel(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + now := time.Now().UnixMilli() + + if err := repo.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'quota_reset_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO tunnel_quota(tunnel_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) + VALUES(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?) + `, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil { + t.Fatalf("insert tunnel_quota: %v", err) + } + + token, err := auth.GenerateToken(1, "admin", 0, secret) + if err != nil { + t.Fatalf("generate token: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/quota/reset", bytes.NewBufferString(`{"tunnelId":1,"scope":"all"}`)) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected reset success, got code=%d msg=%q", out.Code, out.Msg) + } + tunnelStatus := mustQueryInt(t, repo, `SELECT status FROM tunnel WHERE id = 1`) + if tunnelStatus != 1 { + t.Fatalf("expected tunnel to be re-enabled, got %d", tunnelStatus) + } + quotaDisabled := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM tunnel_quota WHERE tunnel_id = 1`) + if quotaDisabled != 0 { + t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled) + } +} diff --git a/plans/032-issue-291-tunnel-traffic-quota.md b/plans/032-issue-291-tunnel-traffic-quota.md new file mode 100644 index 0000000..789cdec --- /dev/null +++ b/plans/032-issue-291-tunnel-traffic-quota.md @@ -0,0 +1,15 @@ +# Issue 291 Tunnel Traffic Quota Plan + +- [x] Confirm quota semantics with issue owner: use existing billed traffic accounting (`traffic_ratio * tunnel.flow`), overage disables the tunnel and pauses active forwards, reset re-enables the tunnel and auto-resumes affected forwards. +- [x] Extend backend schema in `go-backend/internal/store/model/model.go` with a dedicated tunnel quota persistence model that stores per-tunnel daily/monthly limits, current billed usage, rollover keys, and quota-disable metadata in a SQLite/PostgreSQL-safe shape. +- [x] Add repository support in `go-backend/internal/store/repo/` for reading quota settings, atomically rolling day/month windows forward, incrementing billed tunnel usage from flow uploads, checking overage state, marking quota-triggered disable state, clearing usage on manual reset, and listing quota data alongside tunnels. +- [x] Wire billed tunnel usage accumulation into `go-backend/internal/http/handler/flow_policy.go` so each node-reported flow item updates both existing user/user_tunnel counters and the tunnel quota counters using the current billed flow scaling path. +- [x] Implement quota enforcement in backend handlers: when a tunnel crosses quota, set `tunnel.status = 0`, mark it as quota-disabled, pause all active forwards under that tunnel, and persist enough state to distinguish quota shutdown from manual disable. +- [x] Block forward lifecycle operations against quota-disabled or already-over-quota tunnels in `go-backend/internal/http/handler/mutations.go` and related flow-policy checks so create/resume paths fail fast with explicit quota messages. +- [x] Extend the maintenance/reset job in `go-backend/internal/http/handler/jobs.go` to perform daily and monthly quota rollover resets, clear quota-disable flags when limits reset, and auto-resume forwards that were paused by quota enforcement. +- [x] Add manual quota reset API support under `go-backend/internal/http/handler/handler.go` and `go-backend/internal/http/handler/mutations.go` for daily/monthly/all reset scopes, with backend logic to clear counters, re-enable the tunnel, and auto-resume forwards. +- [x] Extend tunnel API payloads in `go-backend/internal/store/repo/repository.go` and handler responses so `tunnel/list` and `tunnel/get` expose quota configuration, usage, reset window state, and quota-disable reason without conflicting with existing `flow` semantics. +- [x] Update backup/import-export structs and repository export/import helpers in `go-backend/internal/store/model/model.go` and `go-backend/internal/store/repo/repository.go` so tunnel quota configuration is preserved across backup/restore; only persist configuration and disable metadata, not stale rolling usage, unless implementation proves current-period restoration is necessary. +- [x] Update frontend tunnel types and API helpers in `vite-frontend/src/api/types.ts`, `vite-frontend/src/types/index.ts`, and `vite-frontend/src/api/index.ts` to accept and submit tunnel quota fields with safe defaults for older payloads. +- [x] Add quota management UI to `vite-frontend/src/pages/tunnel.tsx` for daily/monthly quota inputs, billed usage display, over-quota status, reset actions, and clear tunnel-disabled messaging while preserving existing layout and form conventions. +- [x] Verify behavior with backend contract coverage in `go-backend/tests/contract/` for over-quota disable, create/resume blocking, scheduled reset rollover, manual reset, and auto-resume after reset; run targeted backend tests plus a frontend build validation after implementation. (`go test ./internal/http/handler/... ./tests/contract/...` passed; frontend `npm run build` is currently blocked by missing local dependencies/types in this environment.) diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index acddbec..4f5ca35 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -18,6 +18,7 @@ import type { UserMutationPayload, NodeMutationPayload, TunnelMutationPayload, + TunnelQuotaResetPayload, UserTunnelAssignPayload, UserTunnelListQuery, UserTunnelRemovePayload, @@ -117,6 +118,8 @@ export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id }); export const updateTunnel = (data: TunnelMutationPayload) => Network.post("/tunnel/update", data); +export const resetTunnelQuota = (data: TunnelQuotaResetPayload) => + Network.post("/tunnel/quota/reset", data); export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); export const diagnoseTunnel = (tunnelId: number) => diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 22be0c3..d6b334b 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -36,6 +36,19 @@ export interface TunnelApiItem { name: string; type: number; status: number; + flow?: number; + trafficRatio?: number; + inIp?: string; + ipPreference?: string; + inNodeId?: TunnelChainNodePayload[]; + outNodeId?: TunnelChainNodePayload[]; + chainNodes?: TunnelChainNodePayload[][]; + dailyQuotaGB?: number; + monthlyQuotaGB?: number; + dailyUsedBytes?: number; + monthlyUsedBytes?: number; + disabledByQuota?: number; + quotaDisabledAt?: number; entryNodeId: number; exitNodeId: number; inx?: number; @@ -250,6 +263,8 @@ export interface TunnelMutationPayload { status?: number; flow?: number; trafficRatio?: number; + dailyQuotaGB?: number; + monthlyQuotaGB?: number; inIp?: string; ipPreference?: string; inNodeId?: TunnelChainNodePayload[]; @@ -257,6 +272,11 @@ export interface TunnelMutationPayload { chainNodes?: TunnelChainNodePayload[][]; } +export interface TunnelQuotaResetPayload { + tunnelId: number; + scope?: "daily" | "monthly" | "all"; +} + export interface UserTunnelAssignPayload { userId?: number; id?: number; diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 4ffc5c7..45ad74a 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -41,6 +41,7 @@ import { createTunnel, getTunnelList, updateTunnel, + resetTunnelQuota, deleteTunnel, getNodeList, diagnoseTunnel, @@ -87,6 +88,12 @@ interface Tunnel { protocol?: string; flow: number; // 1: 单向, 2: 双向 trafficRatio: number; + dailyQuotaGB?: number; + monthlyQuotaGB?: number; + dailyUsedBytes?: number; + monthlyUsedBytes?: number; + disabledByQuota?: number; + quotaDisabledAt?: number; ipPreference?: string; status: number; createdTime: string; @@ -111,6 +118,8 @@ interface TunnelForm { chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点 flow: number; trafficRatio: number; + dailyQuotaGB: number; + monthlyQuotaGB: number; inIp: string; // 入口IP ipPreference: string; status: number; @@ -124,6 +133,32 @@ interface BatchProgressState { const TUNNEL_ORDER_KEY = "tunnel-order"; +const formatBytes = (bytes?: number) => { + const value = Number(bytes ?? 0); + + if (!Number.isFinite(value) || value <= 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min( + Math.floor(Math.log(value) / Math.log(1024)), + units.length - 1, + ); + + return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 2)} ${units[index]}`; +}; + +const formatQuotaLimit = (value?: number) => { + const limit = Number(value ?? 0); + + if (!Number.isFinite(limit) || limit <= 0) { + return "不限"; + } + + return `${limit} GB`; +}; + const mapTunnelApiItems = (items: any[]): Tunnel[] => { return (items || []).map((tunnel) => ({ ...tunnel, @@ -134,6 +169,12 @@ const mapTunnelApiItems = (items: any[]): Tunnel[] => { inIp: tunnel.inIp || "", flow: tunnel.flow ?? 1, trafficRatio: tunnel.trafficRatio ?? 1, + dailyQuotaGB: tunnel.dailyQuotaGB ?? 0, + monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0, + dailyUsedBytes: tunnel.dailyUsedBytes ?? 0, + monthlyUsedBytes: tunnel.monthlyUsedBytes ?? 0, + disabledByQuota: tunnel.disabledByQuota ?? 0, + quotaDisabledAt: tunnel.quotaDisabledAt ?? 0, status: typeof tunnel.status === "number" ? tunnel.status : 0, createdTime: tunnel.createdTime || "", })); @@ -156,6 +197,7 @@ export default function TunnelPage() { const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false); const [isEdit, setIsEdit] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); + const [quotaResetLoading, setQuotaResetLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [tunnelToDelete, setTunnelToDelete] = useState(null); @@ -325,6 +367,11 @@ export default function TunnelPage() { return Object.keys(newErrors).length === 0; }; + const editingTunnel = useMemo( + () => tunnels.find((item) => item.id === form.id) || null, + [form.id, tunnels], + ); + // 新增隧道 const handleAdd = () => { setIsEdit(false); @@ -347,6 +394,8 @@ export default function TunnelPage() { chainNodes: tunnel.chainNodes || [], flow: tunnel.flow, trafficRatio: tunnel.trafficRatio, + dailyQuotaGB: tunnel.dailyQuotaGB ?? 0, + monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0, inIp: tunnel.inIp ? tunnel.inIp .split(",") @@ -404,6 +453,28 @@ export default function TunnelPage() { } }; + const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => { + if (!form.id) { + return; + } + + setQuotaResetLoading(true); + try { + const response = await resetTunnelQuota({ tunnelId: form.id, scope }); + + if (response.code === 0) { + toast.success("隧道配额已重置"); + await refreshTunnelList(false); + } else { + toast.error(response.msg || "重置隧道配额失败"); + } + } catch { + toast.error("重置隧道配额失败"); + } finally { + setQuotaResetLoading(false); + } + }; + // 隧道类型改变时的处理 const handleTypeChange = (type: number) => { setForm((prev) => ({ @@ -1357,6 +1428,29 @@ export default function TunnelPage() { )} + +
+
+
+ 每日配额 +
+
+ {formatBytes(tunnel.dailyUsedBytes)} / {formatQuotaLimit(tunnel.dailyQuotaGB)} +
+
+
+
+ 每月配额 +
+
+ {formatBytes(tunnel.monthlyUsedBytes)} / {formatQuotaLimit(tunnel.monthlyQuotaGB)} +
+
+
+ + {tunnel.disabledByQuota ? ( + + ) : null}
@@ -1554,6 +1648,111 @@ export default function TunnelPage() { />
+
+ + setForm((prev) => ({ + ...prev, + dailyQuotaGB: Math.max(0, Number(e.target.value) || 0), + })) + } + /> + + + setForm((prev) => ({ + ...prev, + monthlyQuotaGB: Math.max(0, Number(e.target.value) || 0), + })) + } + /> +
+ + {isEdit && editingTunnel && ( +
+
+
+

+ 当前配额状态 +

+

+ 按现有计费口径统计,重置后会自动恢复该次配额暂停的转发 +

+
+ {editingTunnel.disabledByQuota ? ( + + 配额已触发禁用 + + ) : ( + + 配额正常 + + )} +
+ +
+
+
每日用量
+
+ {formatBytes(editingTunnel.dailyUsedBytes)} / {formatQuotaLimit(editingTunnel.dailyQuotaGB)} +
+
+
+
每月用量
+
+ {formatBytes(editingTunnel.monthlyUsedBytes)} / {formatQuotaLimit(editingTunnel.monthlyQuotaGB)} +
+
+
+ +
+ + + +
+
+ )} +