mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
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:
@@ -44,10 +44,8 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
|||||||
if ok {
|
if ok {
|
||||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||||
if forward, err := h.getForwardRecord(forwardID); err == nil && forward != nil {
|
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||||
if quota, quotaErr := h.repo.AddTunnelQuotaUsage(forward.TunnelID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||||
h.enforceTunnelQuotaIfNeeded(forward.TunnelID, quota)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||||
|
|
||||||
@@ -361,7 +359,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
|||||||
if flowLimit < current {
|
if flowLimit < current {
|
||||||
return errors.New("流量已超额,禁止开启转发")
|
return errors.New("流量已超额,禁止开启转发")
|
||||||
}
|
}
|
||||||
if err := h.ensureTunnelForwardAllowedByQuota(tunnelID, now); err != nil {
|
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
|
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
|
||||||
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
|
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
|
||||||
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
|
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/user/groups", h.userGroups)
|
||||||
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
|
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
|
||||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
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/create", h.tunnelCreate)
|
||||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
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/delete", h.tunnelDelete)
|
||||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
||||||
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.resetMonthlyFlow(now)
|
h.resetMonthlyFlow(now)
|
||||||
h.resetTunnelQuotaWindows(now)
|
h.resetUserQuotaWindows(now)
|
||||||
h.disableExpiredUsers(now.UnixMilli())
|
h.disableExpiredUsers(now.UnixMilli())
|
||||||
h.disableExpiredUserTunnels(now.UnixMilli())
|
h.disableExpiredUserTunnels(now.UnixMilli())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
|
||||||
r, err := repo.Open(dbPath)
|
r, err := repo.Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,29 +157,25 @@ func TestRunResetAndExpiryJobResetsTunnelQuotaAndReEnablesTunnel(t *testing.T) {
|
|||||||
nowMs := now.UnixMilli()
|
nowMs := now.UnixMilli()
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
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(1, 'quota-reset-tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0)
|
VALUES(2, 'quota-reset-user', 'x', 1, 0, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
`, nowMs, nowMs).Error; err != nil {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel: %v", err)
|
t.Fatalf("insert user: %v", err)
|
||||||
}
|
}
|
||||||
if err := r.DB().Exec(`
|
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)
|
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(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
VALUES(2, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
||||||
`, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil {
|
`, 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)
|
h.runResetAndExpiryJob(now)
|
||||||
|
|
||||||
tunnelStatus := mustQueryInt(t, r, `SELECT status FROM tunnel WHERE id = 1`)
|
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`)
|
||||||
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 {
|
if dailyUsed != 0 {
|
||||||
t.Fatalf("expected daily quota usage reset, got %d", dailyUsed)
|
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 {
|
if quotaDisabled != 0 {
|
||||||
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
|
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
num := asInt(req["num"], 10)
|
num := asInt(req["num"], 10)
|
||||||
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
|
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
|
||||||
flowResetTime := asInt64(req["flowResetTime"], 1)
|
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
|
roleID := 1
|
||||||
now := time.Now().UnixMilli()
|
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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
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"])
|
groupIDs := asInt64Slice(req["groupIds"])
|
||||||
if len(groupIDs) > 0 {
|
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())
|
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
|
||||||
flowResetTime := asInt64(req["flowResetTime"], 1)
|
flowResetTime := asInt64(req["flowResetTime"], 1)
|
||||||
status := asInt(req["status"], 1)
|
status := asInt(req["status"], 1)
|
||||||
|
_, hasDailyQuota := req["dailyQuotaGB"]
|
||||||
|
_, hasMonthlyQuota := req["monthlyQuotaGB"]
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
pwd := asString(req["pwd"])
|
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)
|
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 {
|
if groupIDsRaw, ok := req["groupIds"]; ok {
|
||||||
newGroupIDs := asInt64Slice(groupIDsRaw)
|
newGroupIDs := asInt64Slice(groupIDsRaw)
|
||||||
@@ -485,8 +537,6 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
typeVal := asInt(req["type"], 1)
|
typeVal := asInt(req["type"], 1)
|
||||||
flow := asInt64(req["flow"], 1)
|
flow := asInt64(req["flow"], 1)
|
||||||
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
|
|
||||||
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
|
|
||||||
status := asInt(req["status"], 1)
|
status := asInt(req["status"], 1)
|
||||||
trafficRatio := asFloat(req["trafficRatio"], 1.0)
|
trafficRatio := asFloat(req["trafficRatio"], 1.0)
|
||||||
inIP := asString(req["inIp"])
|
inIP := asString(req["inIp"])
|
||||||
@@ -582,10 +632,6 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnelID := tunnel.ID
|
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
|
runtimeState.TunnelID = tunnelID
|
||||||
var federationBindings []repo.FederationTunnelBinding
|
var federationBindings []repo.FederationTunnelBinding
|
||||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||||
@@ -697,8 +743,6 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
typeVal := asInt(req["type"], 1)
|
typeVal := asInt(req["type"], 1)
|
||||||
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
|
|
||||||
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
|
|
||||||
ipPreference := asString(req["ipPreference"])
|
ipPreference := asString(req["ipPreference"])
|
||||||
localDomain := h.federationLocalDomain()
|
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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
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 {
|
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
@@ -1360,10 +1400,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tunnel.Status != 1 {
|
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("隧道已禁用,无法创建转发"))
|
response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法创建转发"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1485,10 +1521,6 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tunnel.Status != 1 {
|
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("隧道已禁用,无法更新转发"))
|
response.WriteJSON(w, response.ErrDefault("隧道已禁用,无法更新转发"))
|
||||||
return
|
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
|
||||||
|
}
|
||||||
@@ -129,8 +129,8 @@ type Tunnel struct {
|
|||||||
|
|
||||||
func (Tunnel) TableName() string { return "tunnel" }
|
func (Tunnel) TableName() string { return "tunnel" }
|
||||||
|
|
||||||
type TunnelQuota struct {
|
type UserQuota struct {
|
||||||
TunnelID int64 `gorm:"column:tunnel_id;primaryKey"`
|
UserID int64 `gorm:"column:user_id;primaryKey"`
|
||||||
DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"`
|
DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"`
|
||||||
MonthlyLimitGB int64 `gorm:"column:monthly_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"`
|
DailyUsedBytes int64 `gorm:"column:daily_used_bytes;not null;default:0"`
|
||||||
@@ -144,7 +144,7 @@ type TunnelQuota struct {
|
|||||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (TunnelQuota) TableName() string { return "tunnel_quota" }
|
func (UserQuota) TableName() string { return "user_quota" }
|
||||||
|
|
||||||
type ChainTunnel struct {
|
type ChainTunnel struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
@@ -338,19 +338,23 @@ type BackupData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserBackup struct {
|
type UserBackup struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Pwd string `json:"pwd"`
|
Pwd string `json:"pwd"`
|
||||||
RoleID int `json:"roleId"`
|
RoleID int `json:"roleId"`
|
||||||
ExpTime int64 `json:"expTime"`
|
ExpTime int64 `json:"expTime"`
|
||||||
Flow int64 `json:"flow"`
|
Flow int64 `json:"flow"`
|
||||||
InFlow int64 `json:"inFlow"`
|
InFlow int64 `json:"inFlow"`
|
||||||
OutFlow int64 `json:"outFlow"`
|
OutFlow int64 `json:"outFlow"`
|
||||||
FlowResetTime int64 `json:"flowResetTime"`
|
FlowResetTime int64 `json:"flowResetTime"`
|
||||||
Num int `json:"num"`
|
DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"`
|
||||||
CreatedTime int64 `json:"createdTime"`
|
MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"`
|
||||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
DisabledByQuota int `json:"disabledByQuota,omitempty"`
|
||||||
Status int `json:"status"`
|
QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"`
|
||||||
|
Num int `json:"num"`
|
||||||
|
CreatedTime int64 `json:"createdTime"`
|
||||||
|
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||||
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeBackup struct {
|
type NodeBackup struct {
|
||||||
@@ -383,23 +387,19 @@ type NodeBackup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TunnelBackup struct {
|
type TunnelBackup struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TrafficRatio float64 `json:"trafficRatio"`
|
TrafficRatio float64 `json:"trafficRatio"`
|
||||||
Type int `json:"type"`
|
Type int `json:"type"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Flow int64 `json:"flow"`
|
Flow int64 `json:"flow"`
|
||||||
CreatedTime int64 `json:"createdTime"`
|
CreatedTime int64 `json:"createdTime"`
|
||||||
UpdatedTime int64 `json:"updatedTime"`
|
UpdatedTime int64 `json:"updatedTime"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
InIP string `json:"inIp,omitempty"`
|
InIP string `json:"inIp,omitempty"`
|
||||||
Inx int `json:"inx"`
|
Inx int `json:"inx"`
|
||||||
IPPreference string `json:"ipPreference,omitempty"`
|
IPPreference string `json:"ipPreference,omitempty"`
|
||||||
DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"`
|
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
|
||||||
MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"`
|
|
||||||
DisabledByQuota int `json:"disabledByQuota,omitempty"`
|
|
||||||
QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"`
|
|
||||||
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChainTunnelBackup struct {
|
type ChainTunnelBackup struct {
|
||||||
@@ -537,8 +537,8 @@ type TunnelRecord struct {
|
|||||||
TrafficRatio float64
|
TrafficRatio float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type TunnelQuotaView struct {
|
type UserQuotaView struct {
|
||||||
TunnelID int64
|
UserID int64
|
||||||
DailyLimitGB int64
|
DailyLimitGB int64
|
||||||
MonthlyLimitGB int64
|
MonthlyLimitGB int64
|
||||||
DailyUsedBytes int64
|
DailyUsedBytes int64
|
||||||
|
|||||||
@@ -161,13 +161,13 @@ func (r *Repository) Close() error {
|
|||||||
func autoMigrateAll(db *gorm.DB) error {
|
func autoMigrateAll(db *gorm.DB) error {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
&model.User{},
|
||||||
|
&model.UserQuota{},
|
||||||
&model.Forward{},
|
&model.Forward{},
|
||||||
&model.ForwardPort{},
|
&model.ForwardPort{},
|
||||||
&model.Node{},
|
&model.Node{},
|
||||||
&model.SpeedLimit{},
|
&model.SpeedLimit{},
|
||||||
&model.StatisticsFlow{},
|
&model.StatisticsFlow{},
|
||||||
&model.Tunnel{},
|
&model.Tunnel{},
|
||||||
&model.TunnelQuota{},
|
|
||||||
&model.ChainTunnel{},
|
&model.ChainTunnel{},
|
||||||
&model.UserTunnel{},
|
&model.UserTunnel{},
|
||||||
&model.TunnelGroup{},
|
&model.TunnelGroup{},
|
||||||
@@ -664,16 +664,33 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
|||||||
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
userIDs := make([]int64, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
userIDs = append(userIDs, u.ID)
|
||||||
|
}
|
||||||
|
quotaMap, err := r.ListUserQuotaViewsByUserIDs(userIDs, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items := make([]map[string]interface{}, 0, len(users))
|
items := make([]map[string]interface{}, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
items = append(items, map[string]interface{}{
|
item := map[string]interface{}{
|
||||||
"id": u.ID, "user": u.User, "name": u.User,
|
"id": u.ID, "user": u.User, "name": u.User,
|
||||||
"roleId": u.RoleID, "status": u.Status,
|
"roleId": u.RoleID, "status": u.Status,
|
||||||
"flow": u.Flow, "num": u.Num, "expTime": u.ExpTime,
|
"flow": u.Flow, "num": u.Num, "expTime": u.ExpTime,
|
||||||
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
|
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
|
||||||
"updatedTime": nullableInt64(u.UpdatedTime),
|
"updatedTime": nullableInt64(u.UpdatedTime),
|
||||||
"inFlow": u.InFlow, "outFlow": u.OutFlow,
|
"inFlow": u.InFlow, "outFlow": u.OutFlow,
|
||||||
})
|
}
|
||||||
|
if quota := quotaMap[u.ID]; quota != nil {
|
||||||
|
item["dailyQuotaGB"] = quota.DailyLimitGB
|
||||||
|
item["monthlyQuotaGB"] = quota.MonthlyLimitGB
|
||||||
|
item["dailyUsedBytes"] = quota.DailyUsedBytes
|
||||||
|
item["monthlyUsedBytes"] = quota.MonthlyUsedBytes
|
||||||
|
item["disabledByQuota"] = quota.DisabledByQuota
|
||||||
|
item["quotaDisabledAt"] = quota.DisabledAt
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -958,7 +975,6 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
tunnelMap := make(map[int64]map[string]interface{})
|
tunnelMap := make(map[int64]map[string]interface{})
|
||||||
orderedIDs := make([]int64, 0, len(tunnels))
|
orderedIDs := make([]int64, 0, len(tunnels))
|
||||||
tunnelIDs := make([]int64, 0, len(tunnels))
|
|
||||||
|
|
||||||
for _, t := range tunnels {
|
for _, t := range tunnels {
|
||||||
tunnelMap[t.ID] = map[string]interface{}{
|
tunnelMap[t.ID] = map[string]interface{}{
|
||||||
@@ -972,24 +988,6 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
|||||||
"chainNodes": make([][]map[string]interface{}, 0),
|
"chainNodes": make([][]map[string]interface{}, 0),
|
||||||
}
|
}
|
||||||
orderedIDs = append(orderedIDs, t.ID)
|
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
|
// Build node IP map
|
||||||
@@ -1814,6 +1812,14 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
|||||||
if err := r.db.Order("id ASC").Find(&users).Error; err != nil {
|
if err := r.db.Order("id ASC").Find(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
userIDs := make([]int64, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
userIDs = append(userIDs, u.ID)
|
||||||
|
}
|
||||||
|
quotaMap, err := r.ListUserQuotaViewsByUserIDs(userIDs, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out := make([]model.UserBackup, 0, len(users))
|
out := make([]model.UserBackup, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
b := model.UserBackup{
|
b := model.UserBackup{
|
||||||
@@ -1822,6 +1828,12 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
|||||||
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||||
}
|
}
|
||||||
|
if quota := quotaMap[u.ID]; quota != nil {
|
||||||
|
b.DailyQuotaGB = quota.DailyLimitGB
|
||||||
|
b.MonthlyQuotaGB = quota.MonthlyLimitGB
|
||||||
|
b.DisabledByQuota = quota.DisabledByQuota
|
||||||
|
b.QuotaDisabledAt = quota.DisabledAt
|
||||||
|
}
|
||||||
if u.UpdatedTime.Valid {
|
if u.UpdatedTime.Valid {
|
||||||
b.UpdatedTime = u.UpdatedTime.Int64
|
b.UpdatedTime = u.UpdatedTime.Int64
|
||||||
}
|
}
|
||||||
@@ -1882,14 +1894,6 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
|
|||||||
if err := r.db.Order("inx ASC, id ASC").Find(&tunnels).Error; err != nil {
|
if err := r.db.Order("inx ASC, id ASC").Find(&tunnels).Error; err != nil {
|
||||||
return nil, err
|
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))
|
out := make([]model.TunnelBackup, 0, len(tunnels))
|
||||||
for _, t := range tunnels {
|
for _, t := range tunnels {
|
||||||
b := model.TunnelBackup{
|
b := model.TunnelBackup{
|
||||||
@@ -1898,12 +1902,6 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
|
|||||||
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
|
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
|
||||||
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
|
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 {
|
if t.InIP.Valid {
|
||||||
b.InIP = t.InIP.String
|
b.InIP = t.InIP.String
|
||||||
}
|
}
|
||||||
@@ -2200,6 +2198,39 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
if u.DailyQuotaGB > 0 || u.MonthlyQuotaGB > 0 || u.DisabledByQuota != 0 || u.QuotaDisabledAt > 0 {
|
||||||
|
current := time.UnixMilli(now)
|
||||||
|
dayKey := int64(current.Year()*10000 + int(current.Month())*100 + current.Day())
|
||||||
|
monthKey := int64(current.Year()*100 + int(current.Month()))
|
||||||
|
quotaItem := model.UserQuota{
|
||||||
|
UserID: u.ID,
|
||||||
|
DailyLimitGB: u.DailyQuotaGB,
|
||||||
|
MonthlyLimitGB: u.MonthlyQuotaGB,
|
||||||
|
DailyUsedBytes: 0,
|
||||||
|
MonthlyUsedBytes: 0,
|
||||||
|
DayKey: dayKey,
|
||||||
|
MonthKey: monthKey,
|
||||||
|
DisabledByQuota: u.DisabledByQuota,
|
||||||
|
DisabledAt: u.QuotaDisabledAt,
|
||||||
|
PausedForwardIDs: "",
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}
|
||||||
|
err = tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"daily_limit_gb", "monthly_limit_gb", "daily_used_bytes", "monthly_used_bytes",
|
||||||
|
"day_key", "month_key", "disabled_by_quota", "disabled_at", "paused_forward_ids", "updated_time",
|
||||||
|
}),
|
||||||
|
}).Create("aItem).Error
|
||||||
|
if err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Where("user_id = ?", u.ID).Delete(&model.UserQuota{}).Error; err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
}
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
@@ -2277,26 +2308,6 @@ func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
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 {
|
for _, ct := range t.ChainTunnels {
|
||||||
chainItem := model.ChainTunnel{
|
chainItem := model.ChainTunnel{
|
||||||
ID: ct.ID,
|
ID: ct.ID,
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ func (r *Repository) DeleteUserCascade(userID int64) error {
|
|||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.StatisticsFlow{}).Error; err != nil {
|
if err := tx.Where("user_id = ?", userID).Delete(&model.StatisticsFlow{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := tx.Where("user_id = ?", userID).Delete(&model.UserQuota{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Where("id = ?", userID).Delete(&model.User{}).Error
|
return tx.Where("id = ?", userID).Delete(&model.User{}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-83
@@ -13,21 +13,21 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tunnelQuotaBytesPerGB int64 = 1024 * 1024 * 1024
|
const userQuotaBytesPerGB int64 = 1024 * 1024 * 1024
|
||||||
|
|
||||||
type TunnelQuotaRelease struct {
|
type UserQuotaRelease struct {
|
||||||
TunnelID int64
|
UserID int64
|
||||||
ForwardIDs []int64
|
ForwardIDs []int64
|
||||||
EnableTunnel bool
|
UnblockUser bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func tunnelQuotaWindowKeys(now time.Time) (int64, int64) {
|
func userQuotaWindowKeys(now time.Time) (int64, int64) {
|
||||||
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()), int64(now.Year()*100 + int(now.Month()))
|
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 {
|
func cloneUserQuotaView(q model.UserQuota) *model.UserQuotaView {
|
||||||
return &model.TunnelQuotaView{
|
return &model.UserQuotaView{
|
||||||
TunnelID: q.TunnelID,
|
UserID: q.UserID,
|
||||||
DailyLimitGB: q.DailyLimitGB,
|
DailyLimitGB: q.DailyLimitGB,
|
||||||
MonthlyLimitGB: q.MonthlyLimitGB,
|
MonthlyLimitGB: q.MonthlyLimitGB,
|
||||||
DailyUsedBytes: q.DailyUsedBytes,
|
DailyUsedBytes: q.DailyUsedBytes,
|
||||||
@@ -40,11 +40,11 @@ func cloneTunnelQuotaView(q model.TunnelQuota) *model.TunnelQuotaView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeTunnelQuotaView(view *model.TunnelQuotaView, now time.Time) *model.TunnelQuotaView {
|
func normalizeUserQuotaView(view *model.UserQuotaView, now time.Time) *model.UserQuotaView {
|
||||||
if view == nil {
|
if view == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dayKey, monthKey := tunnelQuotaWindowKeys(now)
|
dayKey, monthKey := userQuotaWindowKeys(now)
|
||||||
out := *view
|
out := *view
|
||||||
if out.DayKey != dayKey {
|
if out.DayKey != dayKey {
|
||||||
out.DayKey = dayKey
|
out.DayKey = dayKey
|
||||||
@@ -57,14 +57,14 @@ func normalizeTunnelQuotaView(view *model.TunnelQuotaView, now time.Time) *model
|
|||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
func tunnelQuotaExceeded(view *model.TunnelQuotaView) bool {
|
func userQuotaExceeded(view *model.UserQuotaView) bool {
|
||||||
if view == nil {
|
if view == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*tunnelQuotaBytesPerGB {
|
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*userQuotaBytesPerGB {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*tunnelQuotaBytesPerGB {
|
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*userQuotaBytesPerGB {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -107,13 +107,13 @@ func joinPausedForwardIDs(ids []int64) string {
|
|||||||
return strings.Join(parts, ",")
|
return strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) loadOrCreateTunnelQuotaTx(tx *gorm.DB, tunnelID int64, now time.Time) (*model.TunnelQuota, error) {
|
func (r *Repository) loadOrCreateUserQuotaTx(tx *gorm.DB, userID int64, now time.Time) (*model.UserQuota, error) {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return nil, errors.New("database unavailable")
|
return nil, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
dayKey, monthKey := tunnelQuotaWindowKeys(now)
|
dayKey, monthKey := userQuotaWindowKeys(now)
|
||||||
q := &model.TunnelQuota{}
|
q := &model.UserQuota{}
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tunnel_id = ?", tunnelID).First(q).Error
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(q).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,8 @@ func (r *Repository) loadOrCreateTunnelQuotaTx(tx *gorm.DB, tunnelID int64, now
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nowMs := now.UnixMilli()
|
nowMs := now.UnixMilli()
|
||||||
q = &model.TunnelQuota{
|
q = &model.UserQuota{
|
||||||
TunnelID: tunnelID,
|
UserID: userID,
|
||||||
DayKey: dayKey,
|
DayKey: dayKey,
|
||||||
MonthKey: monthKey,
|
MonthKey: monthKey,
|
||||||
CreatedTime: nowMs,
|
CreatedTime: nowMs,
|
||||||
@@ -135,12 +135,12 @@ func (r *Repository) loadOrCreateTunnelQuotaTx(tx *gorm.DB, tunnelID int64, now
|
|||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyTunnelQuotaWindowRoll(q *model.TunnelQuota, now time.Time) bool {
|
func applyUserQuotaWindowRoll(q *model.UserQuota, now time.Time) bool {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
changed := false
|
changed := false
|
||||||
dayKey, monthKey := tunnelQuotaWindowKeys(now)
|
dayKey, monthKey := userQuotaWindowKeys(now)
|
||||||
if q.DayKey != dayKey {
|
if q.DayKey != dayKey {
|
||||||
q.DayKey = dayKey
|
q.DayKey = dayKey
|
||||||
q.DailyUsedBytes = 0
|
q.DailyUsedBytes = 0
|
||||||
@@ -154,18 +154,18 @@ func applyTunnelQuotaWindowRoll(q *model.TunnelQuota, now time.Time) bool {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) SaveTunnelQuotaConfigTx(tx *gorm.DB, tunnelID, dailyLimitGB, monthlyLimitGB int64, now int64) error {
|
func (r *Repository) SaveUserQuotaConfigTx(tx *gorm.DB, userID, dailyLimitGB, monthlyLimitGB int64, now int64) error {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return errors.New("database unavailable")
|
return errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
if tunnelID <= 0 {
|
if userID <= 0 {
|
||||||
return errors.New("tunnel id is required")
|
return errors.New("user id is required")
|
||||||
}
|
}
|
||||||
if dailyLimitGB < 0 || monthlyLimitGB < 0 {
|
if dailyLimitGB < 0 || monthlyLimitGB < 0 {
|
||||||
return errors.New("quota limit cannot be negative")
|
return errors.New("quota limit cannot be negative")
|
||||||
}
|
}
|
||||||
current := time.UnixMilli(now)
|
current := time.UnixMilli(now)
|
||||||
q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, current)
|
q, err := r.loadOrCreateUserQuotaTx(tx, userID, current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -175,69 +175,69 @@ func (r *Repository) SaveTunnelQuotaConfigTx(tx *gorm.DB, tunnelID, dailyLimitGB
|
|||||||
"updated_time": now,
|
"updated_time": now,
|
||||||
}
|
}
|
||||||
if q.DayKey == 0 || q.MonthKey == 0 {
|
if q.DayKey == 0 || q.MonthKey == 0 {
|
||||||
dayKey, monthKey := tunnelQuotaWindowKeys(current)
|
dayKey, monthKey := userQuotaWindowKeys(current)
|
||||||
updates["day_key"] = dayKey
|
updates["day_key"] = dayKey
|
||||||
updates["month_key"] = monthKey
|
updates["month_key"] = monthKey
|
||||||
}
|
}
|
||||||
return tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(updates).Error
|
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(updates).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListTunnelQuotaViewsByTunnelIDs(tunnelIDs []int64, now time.Time) (map[int64]*model.TunnelQuotaView, error) {
|
func (r *Repository) ListUserQuotaViewsByUserIDs(userIDs []int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
out := make(map[int64]*model.TunnelQuotaView)
|
out := make(map[int64]*model.UserQuotaView)
|
||||||
if len(tunnelIDs) == 0 {
|
if len(userIDs) == 0 {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
var rows []model.TunnelQuota
|
var rows []model.UserQuota
|
||||||
if err := r.db.Where("tunnel_id IN ?", tunnelIDs).Find(&rows).Error; err != nil {
|
if err := r.db.Where("user_id IN ?", userIDs).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out[row.TunnelID] = normalizeTunnelQuotaView(cloneTunnelQuotaView(row), now)
|
out[row.UserID] = normalizeUserQuotaView(cloneUserQuotaView(row), now)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) GetTunnelQuotaView(tunnelID int64, now time.Time) (*model.TunnelQuotaView, error) {
|
func (r *Repository) GetUserQuotaView(userID int64, now time.Time) (*model.UserQuotaView, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
if tunnelID <= 0 {
|
if userID <= 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var row model.TunnelQuota
|
var row model.UserQuota
|
||||||
err := r.db.Where("tunnel_id = ?", tunnelID).First(&row).Error
|
err := r.db.Where("user_id = ?", userID).First(&row).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return normalizeTunnelQuotaView(cloneTunnelQuotaView(row), now), nil
|
return normalizeUserQuotaView(cloneUserQuotaView(row), now), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) AddTunnelQuotaUsage(tunnelID int64, usedBytes int64, now time.Time) (*model.TunnelQuotaView, error) {
|
func (r *Repository) AddUserQuotaUsage(userID int64, usedBytes int64, now time.Time) (*model.UserQuotaView, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
if tunnelID <= 0 {
|
if userID <= 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
result := &model.TunnelQuotaView{}
|
result := &model.UserQuotaView{}
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, now)
|
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyTunnelQuotaWindowRoll(q, now)
|
applyUserQuotaWindowRoll(q, now)
|
||||||
if usedBytes > 0 {
|
if usedBytes > 0 {
|
||||||
q.DailyUsedBytes += usedBytes
|
q.DailyUsedBytes += usedBytes
|
||||||
q.MonthlyUsedBytes += usedBytes
|
q.MonthlyUsedBytes += usedBytes
|
||||||
}
|
}
|
||||||
q.UpdatedTime = now.UnixMilli()
|
q.UpdatedTime = now.UnixMilli()
|
||||||
if err := tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{
|
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||||
"daily_used_bytes": q.DailyUsedBytes,
|
"daily_used_bytes": q.DailyUsedBytes,
|
||||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||||
"day_key": q.DayKey,
|
"day_key": q.DayKey,
|
||||||
@@ -246,23 +246,23 @@ func (r *Repository) AddTunnelQuotaUsage(tunnelID int64, usedBytes int64, now ti
|
|||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
*result = *cloneTunnelQuotaView(*q)
|
*result = *cloneUserQuotaView(*q)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return normalizeTunnelQuotaView(result, now), nil
|
return normalizeUserQuotaView(result, now), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) MarkTunnelQuotaDisabled(tunnelID int64, pausedForwardIDs []int64, now int64) error {
|
func (r *Repository) MarkUserQuotaDisabled(userID int64, pausedForwardIDs []int64, now int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
if tunnelID <= 0 {
|
if userID <= 0 {
|
||||||
return errors.New("tunnel id is required")
|
return errors.New("user id is required")
|
||||||
}
|
}
|
||||||
return r.db.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{
|
return r.db.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||||
"disabled_by_quota": 1,
|
"disabled_by_quota": 1,
|
||||||
"disabled_at": now,
|
"disabled_at": now,
|
||||||
"paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs),
|
"paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs),
|
||||||
@@ -270,12 +270,12 @@ func (r *Repository) MarkTunnelQuotaDisabled(tunnelID int64, pausedForwardIDs []
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ResetTunnelQuotaUsage(tunnelID int64, scope string, now time.Time) (*TunnelQuotaRelease, error) {
|
func (r *Repository) ResetUserQuotaUsage(userID int64, scope string, now time.Time) (*UserQuotaRelease, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
if tunnelID <= 0 {
|
if userID <= 0 {
|
||||||
return nil, errors.New("tunnel id is required")
|
return nil, errors.New("user id is required")
|
||||||
}
|
}
|
||||||
scope = strings.TrimSpace(strings.ToLower(scope))
|
scope = strings.TrimSpace(strings.ToLower(scope))
|
||||||
if scope == "" {
|
if scope == "" {
|
||||||
@@ -284,13 +284,13 @@ func (r *Repository) ResetTunnelQuotaUsage(tunnelID int64, scope string, now tim
|
|||||||
if scope != "daily" && scope != "monthly" && scope != "all" {
|
if scope != "daily" && scope != "monthly" && scope != "all" {
|
||||||
return nil, fmt.Errorf("unsupported quota reset scope: %s", scope)
|
return nil, fmt.Errorf("unsupported quota reset scope: %s", scope)
|
||||||
}
|
}
|
||||||
var release *TunnelQuotaRelease
|
var release *UserQuotaRelease
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
q, err := r.loadOrCreateTunnelQuotaTx(tx, tunnelID, now)
|
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyTunnelQuotaWindowRoll(q, now)
|
applyUserQuotaWindowRoll(q, now)
|
||||||
switch scope {
|
switch scope {
|
||||||
case "daily":
|
case "daily":
|
||||||
q.DailyUsedBytes = 0
|
q.DailyUsedBytes = 0
|
||||||
@@ -301,15 +301,15 @@ func (r *Repository) ResetTunnelQuotaUsage(tunnelID int64, scope string, now tim
|
|||||||
q.MonthlyUsedBytes = 0
|
q.MonthlyUsedBytes = 0
|
||||||
}
|
}
|
||||||
q.UpdatedTime = now.UnixMilli()
|
q.UpdatedTime = now.UnixMilli()
|
||||||
release = &TunnelQuotaRelease{TunnelID: tunnelID}
|
release = &UserQuotaRelease{UserID: userID}
|
||||||
if q.DisabledByQuota == 1 && !tunnelQuotaExceeded(cloneTunnelQuotaView(*q)) {
|
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(*q)) {
|
||||||
release.EnableTunnel = true
|
release.UnblockUser = true
|
||||||
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
||||||
q.DisabledByQuota = 0
|
q.DisabledByQuota = 0
|
||||||
q.DisabledAt = 0
|
q.DisabledAt = 0
|
||||||
q.PausedForwardIDs = ""
|
q.PausedForwardIDs = ""
|
||||||
}
|
}
|
||||||
return tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", tunnelID).Updates(map[string]interface{}{
|
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||||
"daily_used_bytes": q.DailyUsedBytes,
|
"daily_used_bytes": q.DailyUsedBytes,
|
||||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||||
"day_key": q.DayKey,
|
"day_key": q.DayKey,
|
||||||
@@ -326,23 +326,23 @@ func (r *Repository) ResetTunnelQuotaUsage(tunnelID int64, scope string, now tim
|
|||||||
return release, nil
|
return release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) RollTunnelQuotaWindows(now time.Time) ([]TunnelQuotaRelease, error) {
|
func (r *Repository) RollUserQuotaWindows(now time.Time) ([]UserQuotaRelease, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var releases []TunnelQuotaRelease
|
var releases []UserQuotaRelease
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var rows []model.TunnelQuota
|
var rows []model.UserQuota
|
||||||
if err := tx.Find(&rows).Error; err != nil {
|
if err := tx.Find(&rows).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
nowMs := now.UnixMilli()
|
nowMs := now.UnixMilli()
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
q := row
|
q := row
|
||||||
changed := applyTunnelQuotaWindowRoll(&q, now)
|
changed := applyUserQuotaWindowRoll(&q, now)
|
||||||
release := TunnelQuotaRelease{TunnelID: q.TunnelID}
|
release := UserQuotaRelease{UserID: q.UserID}
|
||||||
if q.DisabledByQuota == 1 && !tunnelQuotaExceeded(cloneTunnelQuotaView(q)) {
|
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(q)) {
|
||||||
release.EnableTunnel = true
|
release.UnblockUser = true
|
||||||
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
||||||
q.DisabledByQuota = 0
|
q.DisabledByQuota = 0
|
||||||
q.DisabledAt = 0
|
q.DisabledAt = 0
|
||||||
@@ -353,7 +353,7 @@ func (r *Repository) RollTunnelQuotaWindows(now time.Time) ([]TunnelQuotaRelease
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
q.UpdatedTime = nowMs
|
q.UpdatedTime = nowMs
|
||||||
if err := tx.Model(&model.TunnelQuota{}).Where("tunnel_id = ?", q.TunnelID).Updates(map[string]interface{}{
|
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", q.UserID).Updates(map[string]interface{}{
|
||||||
"daily_used_bytes": q.DailyUsedBytes,
|
"daily_used_bytes": q.DailyUsedBytes,
|
||||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||||
"day_key": q.DayKey,
|
"day_key": q.DayKey,
|
||||||
@@ -365,7 +365,7 @@ func (r *Repository) RollTunnelQuotaWindows(now time.Time) ([]TunnelQuotaRelease
|
|||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if release.EnableTunnel {
|
if release.UnblockUser {
|
||||||
releases = append(releases, release)
|
releases = append(releases, release)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,16 +376,3 @@ func (r *Repository) RollTunnelQuotaWindows(now time.Time) ([]TunnelQuotaRelease
|
|||||||
}
|
}
|
||||||
return releases, nil
|
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
|
|
||||||
}
|
|
||||||
+42
-37
@@ -13,21 +13,24 @@ import (
|
|||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestForwardCreateBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
func TestForwardCreateBlockedWhenUserQuotaExceeded(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, repo := setupContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now()
|
||||||
|
nowMs := now.UnixMilli()
|
||||||
|
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||||
|
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||||
|
|
||||||
if err := repo.DB().Exec(`
|
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)
|
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)
|
VALUES(2, 'quota_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
`, now, now).Error; err != nil {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert user: %v", err)
|
t.Fatalf("insert user: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
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)
|
VALUES(1, 'quota_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||||
`, now, now).Error; err != nil {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel: %v", err)
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
@@ -37,10 +40,10 @@ func TestForwardCreateBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
|||||||
t.Fatalf("insert user_tunnel: %v", err)
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
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)
|
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(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
|
||||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil {
|
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel_quota: %v", err)
|
t.Fatalf("insert user_quota: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.GenerateToken(2, "quota_user", 1, secret)
|
token, err := auth.GenerateToken(2, "quota_user", 1, secret)
|
||||||
@@ -59,28 +62,31 @@ func TestForwardCreateBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
|||||||
t.Fatalf("decode response: %v", err)
|
t.Fatalf("decode response: %v", err)
|
||||||
}
|
}
|
||||||
if out.Code == 0 {
|
if out.Code == 0 {
|
||||||
t.Fatalf("expected non-zero code when tunnel quota exceeded")
|
t.Fatalf("expected non-zero code when user quota exceeded")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.Msg, "配额") {
|
if !strings.Contains(out.Msg, "配额") {
|
||||||
t.Fatalf("expected quota error, got %q", out.Msg)
|
t.Fatalf("expected quota error, got %q", out.Msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestForwardResumeBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
func TestForwardResumeBlockedWhenUserQuotaExceeded(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, repo := setupContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now()
|
||||||
|
nowMs := now.UnixMilli()
|
||||||
|
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||||
|
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||||
|
|
||||||
if err := repo.DB().Exec(`
|
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)
|
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)
|
VALUES(2, 'quota_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
`, now, now).Error; err != nil {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert user: %v", err)
|
t.Fatalf("insert user: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
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)
|
VALUES(1, 'quota_resume_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||||
`, now, now).Error; err != nil {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel: %v", err)
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
@@ -92,14 +98,14 @@ func TestForwardResumeBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
|||||||
if err := repo.DB().Exec(`
|
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)
|
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)
|
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 {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert forward: %v", err)
|
t.Fatalf("insert forward: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
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)
|
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(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '1', ?, ?)
|
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '1', ?, ?)
|
||||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil {
|
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel_quota: %v", err)
|
t.Fatalf("insert user_quota: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret)
|
token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret)
|
||||||
@@ -118,7 +124,7 @@ func TestForwardResumeBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
|||||||
t.Fatalf("decode response: %v", err)
|
t.Fatalf("decode response: %v", err)
|
||||||
}
|
}
|
||||||
if out.Code == 0 {
|
if out.Code == 0 {
|
||||||
t.Fatalf("expected non-zero code when tunnel quota exceeded")
|
t.Fatalf("expected non-zero code when user quota exceeded")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.Msg, "配额") {
|
if !strings.Contains(out.Msg, "配额") {
|
||||||
t.Fatalf("expected quota error, got %q", out.Msg)
|
t.Fatalf("expected quota error, got %q", out.Msg)
|
||||||
@@ -129,29 +135,32 @@ func TestForwardResumeBlockedWhenTunnelQuotaExceeded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTunnelQuotaResetReEnablesTunnel(t *testing.T) {
|
func TestUserQuotaResetClearsDisableFlag(t *testing.T) {
|
||||||
secret := "contract-jwt-secret"
|
secret := "contract-jwt-secret"
|
||||||
router, repo := setupContractRouter(t, secret)
|
router, repo := setupContractRouter(t, secret)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now()
|
||||||
|
nowMs := now.UnixMilli()
|
||||||
|
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||||
|
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||||
|
|
||||||
if err := repo.DB().Exec(`
|
if err := repo.DB().Exec(`
|
||||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
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(1, 'quota_reset_tunnel', 1.0, 1, 'tls', 1, ?, ?, 0, NULL, 0)
|
VALUES(2, 'quota_reset_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
`, now, now).Error; err != nil {
|
`, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel: %v", err)
|
t.Fatalf("insert user: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.DB().Exec(`
|
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)
|
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(1, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
|
||||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, now, now, now).Error; err != nil {
|
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
|
||||||
t.Fatalf("insert tunnel_quota: %v", err)
|
t.Fatalf("insert user_quota: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.GenerateToken(1, "admin", 0, secret)
|
token, err := auth.GenerateToken(1, "admin", 0, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate token: %v", err)
|
t.Fatalf("generate token: %v", err)
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/quota/reset", bytes.NewBufferString(`{"tunnelId":1,"scope":"all"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/quota/reset", bytes.NewBufferString(`{"userId":2,"scope":"all"}`))
|
||||||
req.Header.Set("Authorization", token)
|
req.Header.Set("Authorization", token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
res := httptest.NewRecorder()
|
res := httptest.NewRecorder()
|
||||||
@@ -165,11 +174,7 @@ func TestTunnelQuotaResetReEnablesTunnel(t *testing.T) {
|
|||||||
if out.Code != 0 {
|
if out.Code != 0 {
|
||||||
t.Fatalf("expected reset success, got code=%d msg=%q", out.Code, out.Msg)
|
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`)
|
quotaDisabled := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
|
||||||
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 {
|
if quotaDisabled != 0 {
|
||||||
t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled)
|
t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# User Traffic Quota (Fix PR #308 Semantics)
|
||||||
|
|
||||||
|
- [x] Confirm new quota semantics: daily/monthly quota applies per user (aggregated across all tunnels), not per tunnel; overage pauses only that user's active forwards and blocks create/resume.
|
||||||
|
- [x] Backend schema: replace `tunnel_quota` usage with new `user_quota` persistence model + view types.
|
||||||
|
- [x] Repository: implement user quota read/write/increment/reset + daily/monthly window rollover.
|
||||||
|
- [x] Handler: wire quota accumulation into flow uploads, enforce overage (pause forwards + mark quota-disabled), and add admin reset API.
|
||||||
|
- [x] Jobs: run daily quota window rollover + release logic in existing 00:05 maintenance job.
|
||||||
|
- [x] Backup/import: persist quota config + quota-disable metadata on user backup payloads (not rolling usage).
|
||||||
|
- [x] Tests: update contract + handler job tests to validate quota blocking + reset window rollover.
|
||||||
|
- [x] Frontend: move quota inputs/usage/reset UI from tunnel management to user management; update API/types accordingly.
|
||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
UserMutationPayload,
|
UserMutationPayload,
|
||||||
NodeMutationPayload,
|
NodeMutationPayload,
|
||||||
TunnelMutationPayload,
|
TunnelMutationPayload,
|
||||||
TunnelQuotaResetPayload,
|
UserQuotaResetPayload,
|
||||||
UserTunnelAssignPayload,
|
UserTunnelAssignPayload,
|
||||||
UserTunnelListQuery,
|
UserTunnelListQuery,
|
||||||
UserTunnelRemovePayload,
|
UserTunnelRemovePayload,
|
||||||
@@ -118,8 +118,6 @@ export const getTunnelById = (id: number) =>
|
|||||||
Network.post<TunnelApiItem>("/tunnel/get", { id });
|
Network.post<TunnelApiItem>("/tunnel/get", { id });
|
||||||
export const updateTunnel = (data: TunnelMutationPayload) =>
|
export const updateTunnel = (data: TunnelMutationPayload) =>
|
||||||
Network.post("/tunnel/update", data);
|
Network.post("/tunnel/update", data);
|
||||||
export const resetTunnelQuota = (data: TunnelQuotaResetPayload) =>
|
|
||||||
Network.post("/tunnel/quota/reset", data);
|
|
||||||
export const deleteTunnel = (id: number) =>
|
export const deleteTunnel = (id: number) =>
|
||||||
Network.post("/tunnel/delete", { id });
|
Network.post("/tunnel/delete", { id });
|
||||||
export const diagnoseTunnel = (tunnelId: number) =>
|
export const diagnoseTunnel = (tunnelId: number) =>
|
||||||
@@ -196,6 +194,8 @@ export const updatePassword = (data: UpdatePasswordPayload) =>
|
|||||||
// 重置流量接口
|
// 重置流量接口
|
||||||
export const resetUserFlow = (data: { id: number; type: number }) =>
|
export const resetUserFlow = (data: { id: number; type: number }) =>
|
||||||
Network.post("/user/reset", data);
|
Network.post("/user/reset", data);
|
||||||
|
export const resetUserQuota = (data: UserQuotaResetPayload) =>
|
||||||
|
Network.post("/user/quota/reset", data);
|
||||||
|
|
||||||
export const getUserGroups = (id: number) =>
|
export const getUserGroups = (id: number) =>
|
||||||
Network.post<number[]>("/user/groups", { id });
|
Network.post<number[]>("/user/groups", { id });
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface UserApiItem {
|
|||||||
flowResetTime?: number;
|
flowResetTime?: number;
|
||||||
inFlow?: number;
|
inFlow?: number;
|
||||||
outFlow?: number;
|
outFlow?: number;
|
||||||
|
dailyQuotaGB?: number;
|
||||||
|
monthlyQuotaGB?: number;
|
||||||
|
dailyUsedBytes?: number;
|
||||||
|
monthlyUsedBytes?: number;
|
||||||
|
disabledByQuota?: number;
|
||||||
|
quotaDisabledAt?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +49,6 @@ export interface TunnelApiItem {
|
|||||||
inNodeId?: TunnelChainNodePayload[];
|
inNodeId?: TunnelChainNodePayload[];
|
||||||
outNodeId?: TunnelChainNodePayload[];
|
outNodeId?: TunnelChainNodePayload[];
|
||||||
chainNodes?: TunnelChainNodePayload[][];
|
chainNodes?: TunnelChainNodePayload[][];
|
||||||
dailyQuotaGB?: number;
|
|
||||||
monthlyQuotaGB?: number;
|
|
||||||
dailyUsedBytes?: number;
|
|
||||||
monthlyUsedBytes?: number;
|
|
||||||
disabledByQuota?: number;
|
|
||||||
quotaDisabledAt?: number;
|
|
||||||
entryNodeId: number;
|
entryNodeId: number;
|
||||||
exitNodeId: number;
|
exitNodeId: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
@@ -223,6 +223,8 @@ export interface UserMutationPayload {
|
|||||||
num?: number;
|
num?: number;
|
||||||
expTime?: number | string;
|
expTime?: number | string;
|
||||||
flowResetTime?: number;
|
flowResetTime?: number;
|
||||||
|
dailyQuotaGB?: number;
|
||||||
|
monthlyQuotaGB?: number;
|
||||||
tunnelFlow?: number;
|
tunnelFlow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +265,6 @@ export interface TunnelMutationPayload {
|
|||||||
status?: number;
|
status?: number;
|
||||||
flow?: number;
|
flow?: number;
|
||||||
trafficRatio?: number;
|
trafficRatio?: number;
|
||||||
dailyQuotaGB?: number;
|
|
||||||
monthlyQuotaGB?: number;
|
|
||||||
inIp?: string;
|
inIp?: string;
|
||||||
ipPreference?: string;
|
ipPreference?: string;
|
||||||
inNodeId?: TunnelChainNodePayload[];
|
inNodeId?: TunnelChainNodePayload[];
|
||||||
@@ -272,8 +272,8 @@ export interface TunnelMutationPayload {
|
|||||||
chainNodes?: TunnelChainNodePayload[][];
|
chainNodes?: TunnelChainNodePayload[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TunnelQuotaResetPayload {
|
export interface UserQuotaResetPayload {
|
||||||
tunnelId: number;
|
userId: number;
|
||||||
scope?: "daily" | "monthly" | "all";
|
scope?: "daily" | "monthly" | "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
createTunnel,
|
createTunnel,
|
||||||
getTunnelList,
|
getTunnelList,
|
||||||
updateTunnel,
|
updateTunnel,
|
||||||
resetTunnelQuota,
|
|
||||||
deleteTunnel,
|
deleteTunnel,
|
||||||
getNodeList,
|
getNodeList,
|
||||||
diagnoseTunnel,
|
diagnoseTunnel,
|
||||||
@@ -88,12 +87,6 @@ interface Tunnel {
|
|||||||
protocol?: string;
|
protocol?: string;
|
||||||
flow: number; // 1: 单向, 2: 双向
|
flow: number; // 1: 单向, 2: 双向
|
||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
dailyQuotaGB?: number;
|
|
||||||
monthlyQuotaGB?: number;
|
|
||||||
dailyUsedBytes?: number;
|
|
||||||
monthlyUsedBytes?: number;
|
|
||||||
disabledByQuota?: number;
|
|
||||||
quotaDisabledAt?: number;
|
|
||||||
ipPreference?: string;
|
ipPreference?: string;
|
||||||
status: number;
|
status: number;
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
@@ -118,8 +111,6 @@ interface TunnelForm {
|
|||||||
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
|
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
|
||||||
flow: number;
|
flow: number;
|
||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
dailyQuotaGB: number;
|
|
||||||
monthlyQuotaGB: number;
|
|
||||||
inIp: string; // 入口IP
|
inIp: string; // 入口IP
|
||||||
ipPreference: string;
|
ipPreference: string;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -133,32 +124,6 @@ interface BatchProgressState {
|
|||||||
|
|
||||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
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[] => {
|
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||||
return (items || []).map((tunnel) => ({
|
return (items || []).map((tunnel) => ({
|
||||||
...tunnel,
|
...tunnel,
|
||||||
@@ -169,12 +134,6 @@ const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
|||||||
inIp: tunnel.inIp || "",
|
inIp: tunnel.inIp || "",
|
||||||
flow: tunnel.flow ?? 1,
|
flow: tunnel.flow ?? 1,
|
||||||
trafficRatio: tunnel.trafficRatio ?? 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,
|
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
||||||
createdTime: tunnel.createdTime || "",
|
createdTime: tunnel.createdTime || "",
|
||||||
}));
|
}));
|
||||||
@@ -197,7 +156,6 @@ export default function TunnelPage() {
|
|||||||
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||||
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
||||||
@@ -394,8 +352,6 @@ export default function TunnelPage() {
|
|||||||
chainNodes: tunnel.chainNodes || [],
|
chainNodes: tunnel.chainNodes || [],
|
||||||
flow: tunnel.flow,
|
flow: tunnel.flow,
|
||||||
trafficRatio: tunnel.trafficRatio,
|
trafficRatio: tunnel.trafficRatio,
|
||||||
dailyQuotaGB: tunnel.dailyQuotaGB ?? 0,
|
|
||||||
monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0,
|
|
||||||
inIp: tunnel.inIp
|
inIp: tunnel.inIp
|
||||||
? tunnel.inIp
|
? tunnel.inIp
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -407,7 +363,7 @@ export default function TunnelPage() {
|
|||||||
});
|
});
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除隧道
|
// 删除隧道
|
||||||
const handleDelete = (tunnel: Tunnel) => {
|
const handleDelete = (tunnel: Tunnel) => {
|
||||||
@@ -453,28 +409,6 @@ 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) => {
|
const handleTypeChange = (type: number) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
@@ -1429,28 +1363,6 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 mt-2">
|
|
||||||
<div className="rounded bg-default-50 dark:bg-default-100/30 p-2">
|
|
||||||
<div className="text-xs text-default-500">
|
|
||||||
每日配额
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-sm font-semibold text-foreground">
|
|
||||||
{formatBytes(tunnel.dailyUsedBytes)} / {formatQuotaLimit(tunnel.dailyQuotaGB)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded bg-default-50 dark:bg-default-100/30 p-2">
|
|
||||||
<div className="text-xs text-default-500">
|
|
||||||
每月配额
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-sm font-semibold text-foreground">
|
|
||||||
{formatBytes(tunnel.monthlyUsedBytes)} / {formatQuotaLimit(tunnel.monthlyQuotaGB)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tunnel.disabledByQuota ? (
|
|
||||||
<Alert color="danger" title="已因流量配额超额自动禁用并暂停相关转发" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 mt-3">
|
<div className="flex gap-1.5 mt-3">
|
||||||
@@ -1648,111 +1560,6 @@ export default function TunnelPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
errorMessage={errors.dailyQuotaGB}
|
|
||||||
isInvalid={!!errors.dailyQuotaGB}
|
|
||||||
label="每日配额 (GB)"
|
|
||||||
min={0}
|
|
||||||
placeholder="0 表示不限"
|
|
||||||
type="number"
|
|
||||||
value={String(form.dailyQuotaGB ?? 0)}
|
|
||||||
variant="bordered"
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
dailyQuotaGB: Math.max(0, Number(e.target.value) || 0),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
errorMessage={errors.monthlyQuotaGB}
|
|
||||||
isInvalid={!!errors.monthlyQuotaGB}
|
|
||||||
label="每月配额 (GB)"
|
|
||||||
min={0}
|
|
||||||
placeholder="0 表示不限"
|
|
||||||
type="number"
|
|
||||||
value={String(form.monthlyQuotaGB ?? 0)}
|
|
||||||
variant="bordered"
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
monthlyQuotaGB: Math.max(0, Number(e.target.value) || 0),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEdit && editingTunnel && (
|
|
||||||
<div className="space-y-3 rounded-xl border border-default-200 bg-default-50/60 p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
|
||||||
当前配额状态
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-default-500">
|
|
||||||
按现有计费口径统计,重置后会自动恢复该次配额暂停的转发
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{editingTunnel.disabledByQuota ? (
|
|
||||||
<Chip color="danger" size="sm" variant="flat">
|
|
||||||
配额已触发禁用
|
|
||||||
</Chip>
|
|
||||||
) : (
|
|
||||||
<Chip color="success" size="sm" variant="flat">
|
|
||||||
配额正常
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div className="rounded-lg bg-background p-3">
|
|
||||||
<div className="text-xs text-default-500">每日用量</div>
|
|
||||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
|
||||||
{formatBytes(editingTunnel.dailyUsedBytes)} / {formatQuotaLimit(editingTunnel.dailyQuotaGB)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-background p-3">
|
|
||||||
<div className="text-xs text-default-500">每月用量</div>
|
|
||||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
|
||||||
{formatBytes(editingTunnel.monthlyUsedBytes)} / {formatQuotaLimit(editingTunnel.monthlyQuotaGB)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
color="warning"
|
|
||||||
isLoading={quotaResetLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleQuotaReset("daily")}
|
|
||||||
>
|
|
||||||
重置每日配额
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="warning"
|
|
||||||
isLoading={quotaResetLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleQuotaReset("monthly")}
|
|
||||||
>
|
|
||||||
重置每月配额
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
isLoading={quotaResetLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleQuotaReset("all")}
|
|
||||||
>
|
|
||||||
全部重置并恢复
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||||
errorMessage={errors.inIp}
|
errorMessage={errors.inIp}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ interface TunnelFormInput {
|
|||||||
inNodeId: TunnelChainNode[];
|
inNodeId: TunnelChainNode[];
|
||||||
outNodeId?: TunnelChainNode[];
|
outNodeId?: TunnelChainNode[];
|
||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
dailyQuotaGB?: number;
|
|
||||||
monthlyQuotaGB?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TunnelNodeInput {
|
interface TunnelNodeInput {
|
||||||
@@ -26,8 +24,6 @@ export const createTunnelFormDefaults = () => {
|
|||||||
chainNodes: [],
|
chainNodes: [],
|
||||||
flow: 1,
|
flow: 1,
|
||||||
trafficRatio: 1.0,
|
trafficRatio: 1.0,
|
||||||
dailyQuotaGB: 0,
|
|
||||||
monthlyQuotaGB: 0,
|
|
||||||
inIp: "",
|
inIp: "",
|
||||||
ipPreference: "",
|
ipPreference: "",
|
||||||
status: 1,
|
status: 1,
|
||||||
@@ -64,14 +60,6 @@ export const validateTunnelForm = (
|
|||||||
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((form.dailyQuotaGB ?? 0) < 0) {
|
|
||||||
errors.dailyQuotaGB = "每日配额不能小于 0";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((form.monthlyQuotaGB ?? 0) < 0) {
|
|
||||||
errors.monthlyQuotaGB = "每月配额不能小于 0";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.type === 2) {
|
if (form.type === 2) {
|
||||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||||
errors.outNodeId = "请至少选择一个出口节点";
|
errors.outNodeId = "请至少选择一个出口节点";
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
updateUserTunnel,
|
updateUserTunnel,
|
||||||
getSpeedLimitList,
|
getSpeedLimitList,
|
||||||
resetUserFlow,
|
resetUserFlow,
|
||||||
|
resetUserQuota,
|
||||||
getUserGroupList,
|
getUserGroupList,
|
||||||
getUserGroups,
|
getUserGroups,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
@@ -83,6 +84,16 @@ const formatFlow = (value: number, unit: string = "bytes"): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatQuotaLimit = (value?: number): string => {
|
||||||
|
const limit = Number(value ?? 0);
|
||||||
|
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
|
return "不限";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${limit} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (timestamp: number): string => {
|
const formatDate = (timestamp: number): string => {
|
||||||
return new Date(timestamp).toLocaleString();
|
return new Date(timestamp).toLocaleString();
|
||||||
};
|
};
|
||||||
@@ -138,6 +149,12 @@ const normalizeUserItem = (item: Partial<User>): User => {
|
|||||||
createdTime: item.createdTime,
|
createdTime: item.createdTime,
|
||||||
inFlow: Number(item.inFlow ?? 0),
|
inFlow: Number(item.inFlow ?? 0),
|
||||||
outFlow: Number(item.outFlow ?? 0),
|
outFlow: Number(item.outFlow ?? 0),
|
||||||
|
dailyQuotaGB: Number(item.dailyQuotaGB ?? 0),
|
||||||
|
monthlyQuotaGB: Number(item.monthlyQuotaGB ?? 0),
|
||||||
|
dailyUsedBytes: Number(item.dailyUsedBytes ?? 0),
|
||||||
|
monthlyUsedBytes: Number(item.monthlyUsedBytes ?? 0),
|
||||||
|
disabledByQuota: Number(item.disabledByQuota ?? 0),
|
||||||
|
quotaDisabledAt: Number(item.quotaDisabledAt ?? 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,11 +205,22 @@ export default function UserPage() {
|
|||||||
pwd: "",
|
pwd: "",
|
||||||
status: 1,
|
status: 1,
|
||||||
flow: 100,
|
flow: 100,
|
||||||
|
dailyQuotaGB: 0,
|
||||||
|
monthlyQuotaGB: 0,
|
||||||
num: 10,
|
num: 10,
|
||||||
expTime: null,
|
expTime: null,
|
||||||
flowResetTime: 0,
|
flowResetTime: 0,
|
||||||
});
|
});
|
||||||
const [userFormLoading, setUserFormLoading] = useState(false);
|
const [userFormLoading, setUserFormLoading] = useState(false);
|
||||||
|
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
|
||||||
|
|
||||||
|
const editingUser = useMemo(
|
||||||
|
() =>
|
||||||
|
userForm.id
|
||||||
|
? users.find((item) => item.id === userForm.id) || null
|
||||||
|
: null,
|
||||||
|
[userForm.id, users],
|
||||||
|
);
|
||||||
|
|
||||||
// 隧道权限管理相关状态
|
// 隧道权限管理相关状态
|
||||||
const {
|
const {
|
||||||
@@ -442,6 +470,8 @@ export default function UserPage() {
|
|||||||
pwd: "",
|
pwd: "",
|
||||||
status: 1,
|
status: 1,
|
||||||
flow: 100,
|
flow: 100,
|
||||||
|
dailyQuotaGB: 0,
|
||||||
|
monthlyQuotaGB: 0,
|
||||||
num: 10,
|
num: 10,
|
||||||
expTime: null,
|
expTime: null,
|
||||||
flowResetTime: 0,
|
flowResetTime: 0,
|
||||||
@@ -469,6 +499,8 @@ export default function UserPage() {
|
|||||||
pwd: "",
|
pwd: "",
|
||||||
status: user.status,
|
status: user.status,
|
||||||
flow: user.flow,
|
flow: user.flow,
|
||||||
|
dailyQuotaGB: user.dailyQuotaGB ?? 0,
|
||||||
|
monthlyQuotaGB: user.monthlyQuotaGB ?? 0,
|
||||||
num: user.num,
|
num: user.num,
|
||||||
expTime: user.expTime ? new Date(user.expTime) : null,
|
expTime: user.expTime ? new Date(user.expTime) : null,
|
||||||
flowResetTime: user.flowResetTime ?? 0,
|
flowResetTime: user.flowResetTime ?? 0,
|
||||||
@@ -748,6 +780,29 @@ export default function UserPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
|
||||||
|
const userId = userForm.id;
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuotaResetLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await resetUserQuota({ userId, scope });
|
||||||
|
|
||||||
|
if (response.code === 0) {
|
||||||
|
toast.success("用户配额已重置");
|
||||||
|
await loadUsers(searchKeyword);
|
||||||
|
} else {
|
||||||
|
toast.error(response.msg || "重置用户配额失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("重置用户配额失败");
|
||||||
|
} finally {
|
||||||
|
setQuotaResetLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 隧道流量重置相关函数
|
// 隧道流量重置相关函数
|
||||||
const handleResetTunnelFlow = (userTunnel: UserTunnel) => {
|
const handleResetTunnelFlow = (userTunnel: UserTunnel) => {
|
||||||
setTunnelToReset(userTunnel);
|
setTunnelToReset(userTunnel);
|
||||||
@@ -946,6 +1001,16 @@ export default function UserPage() {
|
|||||||
>
|
>
|
||||||
{userStatus.text}
|
{userStatus.text}
|
||||||
</Chip>
|
</Chip>
|
||||||
|
{user.disabledByQuota ? (
|
||||||
|
<Chip
|
||||||
|
className="text-xs"
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
配额超额
|
||||||
|
</Chip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -983,6 +1048,28 @@ export default function UserPage() {
|
|||||||
|
|
||||||
{/* 其他信息 */}
|
{/* 其他信息 */}
|
||||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||||
|
{(user.dailyQuotaGB ?? 0) > 0 ||
|
||||||
|
(user.monthlyQuotaGB ?? 0) > 0 ||
|
||||||
|
(user.disabledByQuota ?? 0) > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-default-600">每日配额</span>
|
||||||
|
<span className="font-medium text-xs">
|
||||||
|
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /
|
||||||
|
{" "}
|
||||||
|
{formatQuotaLimit(user.dailyQuotaGB)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-default-600">每月配额</span>
|
||||||
|
<span className="font-medium text-xs">
|
||||||
|
{formatFlow(Number(user.monthlyUsedBytes ?? 0))} /
|
||||||
|
{" "}
|
||||||
|
{formatQuotaLimit(user.monthlyQuotaGB)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-default-600">规则数量</span>
|
<span className="text-default-600">规则数量</span>
|
||||||
<span className="font-medium text-xs">
|
<span className="font-medium text-xs">
|
||||||
@@ -1138,6 +1225,38 @@ export default function UserPage() {
|
|||||||
setUserForm((prev) => ({ ...prev, flow: value }));
|
setUserForm((prev) => ({ ...prev, flow: value }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label="每日配额(GB)"
|
||||||
|
max="99999"
|
||||||
|
min="0"
|
||||||
|
placeholder="0 表示不限"
|
||||||
|
type="number"
|
||||||
|
value={userForm.dailyQuotaGB.toString()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Math.min(
|
||||||
|
Math.max(Number(e.target.value) || 0, 0),
|
||||||
|
99999,
|
||||||
|
);
|
||||||
|
|
||||||
|
setUserForm((prev) => ({ ...prev, dailyQuotaGB: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="每月配额(GB)"
|
||||||
|
max="99999"
|
||||||
|
min="0"
|
||||||
|
placeholder="0 表示不限"
|
||||||
|
type="number"
|
||||||
|
value={userForm.monthlyQuotaGB.toString()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Math.min(
|
||||||
|
Math.max(Number(e.target.value) || 0, 0),
|
||||||
|
99999,
|
||||||
|
);
|
||||||
|
|
||||||
|
setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
isRequired
|
isRequired
|
||||||
label="规则数量"
|
label="规则数量"
|
||||||
@@ -1210,6 +1329,81 @@ export default function UserPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEdit &&
|
||||||
|
editingUser &&
|
||||||
|
((editingUser.dailyQuotaGB ?? 0) > 0 ||
|
||||||
|
(editingUser.monthlyQuotaGB ?? 0) > 0 ||
|
||||||
|
(editingUser.disabledByQuota ?? 0) > 0) && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-default-200 bg-default-50/60 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
当前配额状态
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-default-500">
|
||||||
|
配额超额后会自动暂停该用户的转发,重置后可恢复
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{editingUser.disabledByQuota ? (
|
||||||
|
<Chip color="danger" size="sm" variant="flat">
|
||||||
|
配额已触发禁用
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip color="success" size="sm" variant="flat">
|
||||||
|
配额正常
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div className="rounded-lg bg-background p-3">
|
||||||
|
<div className="text-xs text-default-500">每日用量</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||||
|
{formatFlow(Number(editingUser.dailyUsedBytes ?? 0))} /{" "}
|
||||||
|
{formatQuotaLimit(editingUser.dailyQuotaGB)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-background p-3">
|
||||||
|
<div className="text-xs text-default-500">每月用量</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||||
|
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "}
|
||||||
|
{formatQuotaLimit(editingUser.monthlyQuotaGB)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
color="warning"
|
||||||
|
isLoading={quotaResetLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleQuotaReset("daily")}
|
||||||
|
>
|
||||||
|
重置每日配额
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="warning"
|
||||||
|
isLoading={quotaResetLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleQuotaReset("monthly")}
|
||||||
|
>
|
||||||
|
重置每月配额
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={quotaResetLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleQuotaReset("all")}
|
||||||
|
>
|
||||||
|
全部重置并恢复
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
label="状态"
|
label="状态"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export interface User {
|
|||||||
createdTime?: number; // 创建时间戳
|
createdTime?: number; // 创建时间戳
|
||||||
inFlow?: number; // 下载流量(字节)
|
inFlow?: number; // 下载流量(字节)
|
||||||
outFlow?: number; // 上传流量(字节)
|
outFlow?: number; // 上传流量(字节)
|
||||||
|
dailyQuotaGB?: number;
|
||||||
|
monthlyQuotaGB?: number;
|
||||||
|
dailyUsedBytes?: number;
|
||||||
|
monthlyUsedBytes?: number;
|
||||||
|
disabledByQuota?: number;
|
||||||
|
quotaDisabledAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserGroup {
|
export interface UserGroup {
|
||||||
@@ -33,6 +39,8 @@ export interface UserForm {
|
|||||||
pwd?: string;
|
pwd?: string;
|
||||||
status: number;
|
status: number;
|
||||||
flow: number;
|
flow: number;
|
||||||
|
dailyQuotaGB: number;
|
||||||
|
monthlyQuotaGB: number;
|
||||||
num: number;
|
num: number;
|
||||||
expTime: Date | null;
|
expTime: Date | null;
|
||||||
flowResetTime: number;
|
flowResetTime: number;
|
||||||
@@ -83,11 +91,6 @@ export interface Tunnel {
|
|||||||
exitNodeName?: string;
|
exitNodeName?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
flow?: number; // 流量计算类型
|
flow?: number; // 流量计算类型
|
||||||
dailyQuotaGB?: number;
|
|
||||||
monthlyQuotaGB?: number;
|
|
||||||
dailyUsedBytes?: number;
|
|
||||||
monthlyUsedBytes?: number;
|
|
||||||
disabledByQuota?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeedLimit {
|
export interface SpeedLimit {
|
||||||
|
|||||||
Reference in New Issue
Block a user