refactor(quota): migrate traffic quota from tunnel to user level

- Replace tunnel_quota table with user_quota table
- Add user-level daily/monthly quota tracking and enforcement
- Update user CRUD to include quota configuration
- Migrate backup/restore to use user quota fields
- Update frontend API and UI for user quota management
This commit is contained in:
sagitchu
2026-03-12 14:17:57 +08:00
parent 30d9552207
commit ad9b336fb9
19 changed files with 648 additions and 615 deletions
@@ -44,10 +44,8 @@ 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)
}
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
}
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
@@ -361,7 +359,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
if flowLimit < current {
return errors.New("流量已超额,禁止开启转发")
}
if err := h.ensureTunnelForwardAllowedByQuota(tunnelID, now); err != nil {
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
return err
}
+1 -1
View File
@@ -101,6 +101,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
@@ -132,7 +133,6 @@ 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)
+1 -1
View File
@@ -136,7 +136,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
}
h.resetMonthlyFlow(now)
h.resetTunnelQuotaWindows(now)
h.resetUserQuotaWindows(now)
h.disableExpiredUsers(now.UnixMilli())
h.disableExpiredUserTunnels(now.UnixMilli())
}
+9 -13
View File
@@ -144,7 +144,7 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
}
}
func TestRunResetAndExpiryJobResetsTunnelQuotaAndReEnablesTunnel(t *testing.T) {
func TestRunResetAndExpiryJobResetsUserQuotaAndUnblocksUser(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
r, err := repo.Open(dbPath)
if err != nil {
@@ -157,29 +157,25 @@ func TestRunResetAndExpiryJobResetsTunnelQuotaAndReEnablesTunnel(t *testing.T) {
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)
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-reset-user', 'x', 1, 0, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
t.Fatalf("insert user: %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, ?, '', ?, ?)
INSERT INTO user_quota(user_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(2, 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)
t.Fatalf("insert user 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`)
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`)
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`)
quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
if quotaDisabled != 0 {
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
}
+52 -20
View File
@@ -60,6 +60,12 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
num := asInt(req["num"], 10)
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1)
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
if dailyQuotaGB < 0 || monthlyQuotaGB < 0 {
response.WriteJSON(w, response.ErrDefault("配额不能小于0"))
return
}
roleID := 1
now := time.Now().UnixMilli()
@@ -68,6 +74,22 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if dailyQuotaGB > 0 || monthlyQuotaGB > 0 {
tx := h.repo.BeginTx()
if tx == nil || tx.Error != nil {
response.WriteJSON(w, response.Err(-2, "database unavailable"))
return
}
defer func() { tx.Rollback() }()
if err := h.repo.SaveUserQuotaConfigTx(tx, userID, dailyQuotaGB, monthlyQuotaGB, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
groupIDs := asInt64Slice(req["groupIds"])
if len(groupIDs) > 0 {
@@ -131,6 +153,8 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1)
status := asInt(req["status"], 1)
_, hasDailyQuota := req["dailyQuotaGB"]
_, hasMonthlyQuota := req["monthlyQuotaGB"]
now := time.Now().UnixMilli()
pwd := asString(req["pwd"])
@@ -147,6 +171,34 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
}
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)
if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
if !(hasDailyQuota && hasMonthlyQuota) {
if currentQuota, err := h.repo.GetUserQuotaView(id, time.Now()); err == nil && currentQuota != nil {
if !hasDailyQuota {
dailyQuotaGB = currentQuota.DailyLimitGB
}
if !hasMonthlyQuota {
monthlyQuotaGB = currentQuota.MonthlyLimitGB
}
}
}
tx := h.repo.BeginTx()
if tx == nil || tx.Error != nil {
response.WriteJSON(w, response.Err(-2, "database unavailable"))
return
}
defer func() { tx.Rollback() }()
if err := h.repo.SaveUserQuotaConfigTx(tx, id, dailyQuotaGB, monthlyQuotaGB, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
if groupIDsRaw, ok := req["groupIds"]; ok {
newGroupIDs := asInt64Slice(groupIDsRaw)
@@ -485,8 +537,6 @@ 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"])
@@ -582,10 +632,6 @@ 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
@@ -697,8 +743,6 @@ 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()
@@ -743,10 +787,6 @@ 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()))
@@ -1360,10 +1400,6 @@ 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
}
@@ -1485,10 +1521,6 @@ 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
}
@@ -1,140 +0,0 @@
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
}
@@ -0,0 +1,139 @@
package handler
import (
"errors"
"net/http"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func isUserQuotaExceeded(view *model.UserQuotaView) 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) userQuotaBlockReason(userID int64, now int64) (string, error) {
if h == nil || h.repo == nil || userID <= 0 {
return "", nil
}
quota, err := h.repo.GetUserQuotaView(userID, time.UnixMilli(now))
if err != nil || quota == nil {
return "", err
}
if quota.DisabledByQuota == 1 || isUserQuotaExceeded(quota) {
return "该用户流量配额已超额,禁止开启转发", nil
}
return "", nil
}
func (h *Handler) enforceUserQuotaIfNeeded(userID int64, quota *model.UserQuotaView) {
if h == nil || h.repo == nil || userID <= 0 || quota == nil {
return
}
if quota.DisabledByQuota == 1 || !isUserQuotaExceeded(quota) {
return
}
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return
}
pausedIDs := make([]int64, 0, len(forwards))
now := time.Now().UnixMilli()
for i := range forwards {
forward := &forwards[i]
if forward.Status != 1 {
continue
}
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
continue
}
pausedIDs = append(pausedIDs, forward.ID)
}
_ = h.repo.MarkUserQuotaDisabled(userID, pausedIDs, now)
}
func (h *Handler) applyUserQuotaRelease(release *repo.UserQuotaRelease, now int64) {
if h == nil || h.repo == nil || release == nil || release.UserID <= 0 || !release.UnblockUser {
return
}
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) resetUserQuotaWindows(now time.Time) {
if h == nil || h.repo == nil {
return
}
releases, err := h.repo.RollUserQuotaWindows(now)
if err != nil {
return
}
nowMs := now.UnixMilli()
for i := range releases {
h.applyUserQuotaRelease(&releases[i], nowMs)
}
}
func (h *Handler) userQuotaReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
UserID int64 `json:"userId"`
Scope string `json:"scope"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("用户ID不能为空"))
return
}
release, err := h.repo.ResetUserQuotaUsage(req.UserID, req.Scope, time.Now())
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
nowMs := time.Now().UnixMilli()
h.applyUserQuotaRelease(release, nowMs)
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) ensureUserForwardAllowedByQuota(userID int64, now int64) error {
reason, err := h.userQuotaBlockReason(userID, now)
if err != nil {
return err
}
if reason != "" {
return errors.New(reason)
}
return nil
}