feat: 实现每用户流量统计与定时落库
流量统计从全站按日聚合升级为 per-user 维度,并引入 60 秒定时 落库机制,服务崩溃最多丢失 60 秒数据。 后端: - model/vpn.go: 新增 UserTrafficStat 模型(user_id + date 联合唯一索引) - db/db.go: AutoMigrate 注册新模型 - vpn/tunnel.go: tunnelConn 增加 flushedRx/flushedTx 快照字段 + flushDelta() 增量计算;recordTraffic 改为 per-user 双写 (user_traffic_stats + traffic_stats);连接断开 defer 改增量落库 - vpn/service.go: 新增 flushDone 字段 + trafficFlusher(60s 定时 落库)+ flushAllTraffic;Stop() 先停 flusher 再最终 flush 全部 在线连接增量;TotalLiveTraffic 改返回未落库增量避免与 DB 重复; 新增 UserLiveTraffic(userID);ClientInfo 增加 RxBytes/TxBytes - handler/traffic.go: 新建 5 个 handler(admin 3 个 + user 2 个) - router.go: 注册 5 条新路由 新增 API: - GET /api/admin/traffic/today 所有用户今日流量排行 - GET /api/admin/traffic/history?days=N 全站近 N 天流量历史 - GET /api/admin/traffic/users/:id?days=N 指定用户近 N 天流量 - GET /api/me/traffic/today 自己的今日流量 - GET /api/me/traffic?days=N 自己的近 N 天流量 前端: - 安装 chart.js + vue-chartjs - 新建 TrafficChart.vue 可复用柱状图组件(上行/下行双柱) - AdminView.vue: 在线客户端表格增加 RX/TX 列 + 全站 7 天流量 图表 + 用户今日流量排行表 - ProfileView.vue: 新增流量统计卡片(今日上行/下行/合计)+ 7 天 流量柱状图 - zh.ts/en.ts: 新增 traffic 相关国际化
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/db"
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/vpn"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type userTrafficItem struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
type trafficRecord struct {
|
||||
Date string `json:"date"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
func parseDays(c *gin.Context) int {
|
||||
days := 7
|
||||
if d := c.Query("days"); d != "" {
|
||||
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
|
||||
days = n
|
||||
}
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func GetAdminTrafficToday(c *gin.Context) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
var stats []model.UserTrafficStat
|
||||
db.DB.Where("date = ?", today).Find(&stats)
|
||||
|
||||
userIDs := make([]uint, 0, len(stats))
|
||||
for _, s := range stats {
|
||||
userIDs = append(userIDs, s.UserID)
|
||||
}
|
||||
nameMap := make(map[uint]string)
|
||||
if len(userIDs) > 0 {
|
||||
var users []model.User
|
||||
db.DB.Where("id IN ?", userIDs).Find(&users)
|
||||
for _, u := range users {
|
||||
nameMap[u.ID] = u.Username
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]userTrafficItem, 0, len(stats))
|
||||
var totalRx, totalTx int64
|
||||
seen := make(map[uint]bool)
|
||||
for _, s := range stats {
|
||||
liveRx, liveTx := int64(0), int64(0)
|
||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||
liveRx, liveTx = vpn.VPN.UserLiveTraffic(s.UserID)
|
||||
}
|
||||
rx := s.RxBytes + liveRx
|
||||
tx := s.TxBytes + liveTx
|
||||
items = append(items, userTrafficItem{
|
||||
UserID: s.UserID,
|
||||
Username: nameMap[s.UserID],
|
||||
RxBytes: rx,
|
||||
TxBytes: tx,
|
||||
TotalBytes: rx + tx,
|
||||
})
|
||||
totalRx += rx
|
||||
totalTx += tx
|
||||
seen[s.UserID] = true
|
||||
}
|
||||
|
||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||
for _, ci := range vpn.VPN.ClientList() {
|
||||
if seen[ci.UserID] {
|
||||
continue
|
||||
}
|
||||
liveRx, liveTx := vpn.VPN.UserLiveTraffic(ci.UserID)
|
||||
if liveRx == 0 && liveTx == 0 {
|
||||
continue
|
||||
}
|
||||
var u model.User
|
||||
if err := db.DB.First(&u, ci.UserID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, userTrafficItem{
|
||||
UserID: ci.UserID,
|
||||
Username: u.Username,
|
||||
RxBytes: liveRx,
|
||||
TxBytes: liveTx,
|
||||
TotalBytes: liveRx + liveTx,
|
||||
})
|
||||
totalRx += liveRx
|
||||
totalTx += liveTx
|
||||
seen[ci.UserID] = true
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_rx_bytes": totalRx,
|
||||
"total_tx_bytes": totalTx,
|
||||
"users": items,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminTrafficHistory(c *gin.Context) {
|
||||
days := parseDays(c)
|
||||
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||||
var stats []model.TrafficStat
|
||||
db.DB.Where("date >= ?", startDate).Order("date asc").Find(&stats)
|
||||
|
||||
dateMap := make(map[string]trafficRecord, len(stats))
|
||||
for _, s := range stats {
|
||||
dateMap[s.Date] = trafficRecord{
|
||||
Date: s.Date,
|
||||
RxBytes: s.RxBytes,
|
||||
TxBytes: s.TxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]trafficRecord, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||
if r, ok := dateMap[d]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, trafficRecord{Date: d})
|
||||
}
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayStat model.TrafficStat
|
||||
db.DB.Where("date = ?", today).First(&todayStat)
|
||||
liveRx, liveTx := int64(0), int64(0)
|
||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||
liveRx, liveTx = vpn.VPN.TotalLiveTraffic()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"today_rx_bytes": todayStat.RxBytes + liveRx,
|
||||
"today_tx_bytes": todayStat.TxBytes + liveTx,
|
||||
"records": out,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminUserTraffic(c *gin.Context) { idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
days := parseDays(c)
|
||||
records := queryUserTraffic(uint(id), days)
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayStat model.UserTrafficStat
|
||||
db.DB.Where("user_id = ? AND date = ?", id, today).First(&todayStat)
|
||||
liveRx, liveTx := int64(0), int64(0)
|
||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uint(id))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": user.ID,
|
||||
"username": user.Username,
|
||||
"today_rx_bytes": todayStat.RxBytes + liveRx,
|
||||
"today_tx_bytes": todayStat.TxBytes + liveTx,
|
||||
"today_live_rx": liveRx,
|
||||
"today_live_tx": liveTx,
|
||||
"records": records,
|
||||
})
|
||||
}
|
||||
|
||||
func GetMyTrafficToday(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
uid := userID.(uint)
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var stat model.UserTrafficStat
|
||||
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&stat)
|
||||
|
||||
liveRx, liveTx := int64(0), int64(0)
|
||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"rx_bytes": stat.RxBytes + liveRx,
|
||||
"tx_bytes": stat.TxBytes + liveTx,
|
||||
})
|
||||
}
|
||||
|
||||
func GetMyTrafficHistory(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
uid := userID.(uint)
|
||||
|
||||
days := parseDays(c)
|
||||
records := queryUserTraffic(uid, days)
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayStat model.UserTrafficStat
|
||||
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&todayStat)
|
||||
liveRx, liveTx := int64(0), int64(0)
|
||||
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"today_rx_bytes": todayStat.RxBytes + liveRx,
|
||||
"today_tx_bytes": todayStat.TxBytes + liveTx,
|
||||
"today_live_rx": liveRx,
|
||||
"today_live_tx": liveTx,
|
||||
"records": records,
|
||||
})
|
||||
}
|
||||
|
||||
func queryUserTraffic(userID uint, days int) []trafficRecord {
|
||||
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||||
var stats []model.UserTrafficStat
|
||||
db.DB.Where("user_id = ? AND date >= ?", userID, startDate).Order("date asc").Find(&stats)
|
||||
|
||||
dateMap := make(map[string]trafficRecord, len(stats))
|
||||
for _, s := range stats {
|
||||
dateMap[s.Date] = trafficRecord{
|
||||
Date: s.Date,
|
||||
RxBytes: s.RxBytes,
|
||||
TxBytes: s.TxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]trafficRecord, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||
if r, ok := dateMap[d]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, trafficRecord{Date: d})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user