diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c68359..888662e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "chart.js": "^4.5.1", "pinia": "^3.0.4", "vue": "^3.5.38", + "vue-chartjs": "^5.3.4", "vue-i18n": "^11.4.6", "vue-router": "^5.1.0" }, @@ -625,6 +627,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1734,6 +1742,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -3405,6 +3425,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz", + "integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-i18n": { "version": "11.4.6", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0fdc870..7a33179 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,10 @@ "type-check": "vue-tsc --build" }, "dependencies": { + "chart.js": "^4.5.1", "pinia": "^3.0.4", "vue": "^3.5.38", + "vue-chartjs": "^5.3.4", "vue-i18n": "^11.4.6", "vue-router": "^5.1.0" }, diff --git a/frontend/src/components/TrafficChart.vue b/frontend/src/components/TrafficChart.vue new file mode 100644 index 0000000..e32faf9 --- /dev/null +++ b/frontend/src/components/TrafficChart.vue @@ -0,0 +1,87 @@ + + + + + + + diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index cf923aa..f935dc8 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -95,6 +95,18 @@ export default { 'Are you sure you want to delete user {username}? This action cannot be undone.', confirmDeleteButton: 'Confirm Delete', }, + traffic: { + myTraffic: 'My Traffic', + userTrafficToday: "User Traffic Today", + todayTraffic: "Today's Traffic", + upload: 'Upload', + download: 'Download', + total: 'Total', + history: 'Traffic History', + date: 'Date', + noTrafficData: 'No traffic data', + trafficHistory7d: 'Traffic - Last 7 Days', + }, vpn: { title: 'VPN Management', refresh: 'Refresh', diff --git a/frontend/src/locales/zh.ts b/frontend/src/locales/zh.ts index eab44ed..226381f 100644 --- a/frontend/src/locales/zh.ts +++ b/frontend/src/locales/zh.ts @@ -94,6 +94,18 @@ export default { confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。', confirmDeleteButton: '确认删除', }, + traffic: { + myTraffic: '我的流量统计', + userTrafficToday: '用户今日流量', + todayTraffic: '今日流量', + upload: '上行', + download: '下行', + total: '合计', + history: '流量历史', + date: '日期', + noTrafficData: '暂无流量数据', + trafficHistory7d: '近 7 天流量', + }, vpn: { title: 'VPN 管理', refresh: '刷新', diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 66e9929..456ddef 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAuthStore } from '@/stores/auth' +import TrafficChart from '@/components/TrafficChart.vue' const router = useRouter() const authStore = useAuthStore() @@ -25,10 +26,35 @@ interface ClientInfo { ip: string ip6?: string connected_at: string + rx_bytes: number + tx_bytes: number } const vpnClients = ref([]) const kickError = ref('') +interface UserTraffic { + user_id: number + username: string + rx_bytes: number + tx_bytes: number + total_bytes: number +} +const userTrafficToday = ref([]) + +interface TrafficRecord { + date: string + rx_bytes: number + tx_bytes: number +} +const siteTraffic7d = ref([]) + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i] +} + function formatUptime(seconds: number): string { if (seconds <= 0) return '0m' const d = Math.floor(seconds / 86400) @@ -82,6 +108,28 @@ async function fetchVpnStatus() { } catch {} } +async function fetchTrafficToday() { + try { + const res = await fetch('/api/admin/traffic/today', { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) + if (!res.ok) return + const data = await res.json() + userTrafficToday.value = (data.users || []).sort((a: UserTraffic, b: UserTraffic) => b.total_bytes - a.total_bytes) + } catch {} +} + +async function fetchSiteTraffic7d() { + try { + const res = await fetch('/api/admin/traffic/history?days=7', { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) + if (!res.ok) return + const data = await res.json() + siteTraffic7d.value = data.records || [] + } catch {} +} + async function handleKick(userId: number, username: string) { kickError.value = '' if (userId === authStore.user?.id) { @@ -107,9 +155,12 @@ onMounted(async () => { fetchUserCount() fetchStats() fetchVpnStatus() + fetchTrafficToday() + fetchSiteTraffic7d() statsTimer = setInterval(() => { fetchStats() fetchVpnStatus() + fetchTrafficToday() }, 30000) }) @@ -167,18 +218,22 @@ function handleStatClick(route: string) { {{ t('vpn.ipv4') }} {{ t('vpn.ipv6') }} {{ t('vpn.connectTime') }} + {{ t('traffic.upload') }} + {{ t('traffic.download') }} {{ t('common.actions') }} - {{ t('vpn.noOnlineClients') }} + {{ t('vpn.noOnlineClients') }} {{ c.username }} {{ c.ip }} {{ c.ip6 || '-' }} {{ c.connected_at }} + {{ formatBytes(c.rx_bytes) }} + {{ formatBytes(c.tx_bytes) }} {{ kickError }} + + + {{ t('traffic.trafficHistory7d') }} + + + + + {{ t('traffic.userTrafficToday') }} + + + + {{ t('common.username') }} + {{ t('traffic.upload') }} + {{ t('traffic.download') }} + {{ t('traffic.total') }} + + + + + {{ t('traffic.noTrafficData') }} + + + {{ u.username }} + {{ formatBytes(u.rx_bytes) }} + {{ formatBytes(u.tx_bytes) }} + {{ formatBytes(u.total_bytes) }} + + + + diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 1192413..ec75245 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAuthStore } from '@/stores/auth' +import TrafficChart from '@/components/TrafficChart.vue' const authStore = useAuthStore() const router = useRouter() @@ -16,6 +17,35 @@ interface VpnConnection { const vpnConnections = ref([]) const maxConns = ref(30) +interface TrafficRecord { + date: string + rx_bytes: number + tx_bytes: number +} +const myTraffic7d = ref([]) +const todayRx = ref(0) +const todayTx = ref(0) + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i] +} + +async function fetchMyTraffic() { + try { + const res = await fetch('/api/me/traffic?days=7', { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) + if (!res.ok) return + const data = await res.json() + myTraffic7d.value = data.records || [] + todayRx.value = data.today_rx_bytes || 0 + todayTx.value = data.today_tx_bytes || 0 + } catch {} +} + async function fetchVpnConnections() { try { const res = await fetch('/api/me/vpn/connections', { @@ -31,6 +61,7 @@ async function fetchVpnConnections() { onMounted(async () => { await authStore.fetchUser() fetchVpnConnections() + fetchMyTraffic() }) const showPasswordModal = ref(false) @@ -94,6 +125,26 @@ async function handleChangePassword() { + + {{ t('traffic.myTraffic') }} + + + {{ t('traffic.upload') }} + {{ formatBytes(todayRx) }} + + + {{ t('traffic.download') }} + {{ formatBytes(todayTx) }} + + + {{ t('traffic.total') }} + {{ formatBytes(todayRx + todayTx) }} + + + {{ t('traffic.trafficHistory7d') }} + + + {{ t('profile.myVpnConnections') }} diff --git a/internal/db/db.go b/internal/db/db.go index 1aeb66f..b2a349e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error { return fmt.Errorf("数据库连接失败: %w", err) } - if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}); err != nil { + if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}, &model.UserTrafficStat{}); err != nil { return fmt.Errorf("数据库迁移失败: %w", err) } diff --git a/internal/handler/traffic.go b/internal/handler/traffic.go new file mode 100644 index 0000000..ed9fabb --- /dev/null +++ b/internal/handler/traffic.go @@ -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 +} diff --git a/internal/model/vpn.go b/internal/model/vpn.go index 1362acf..03a1665 100644 --- a/internal/model/vpn.go +++ b/internal/model/vpn.go @@ -45,3 +45,16 @@ type TrafficStat struct { func (TrafficStat) TableName() string { return "traffic_stats" } + +type UserTrafficStat struct { + ID uint `gorm:"primaryKey;autoIncrement"` + UserID uint `gorm:"uniqueIndex:idx_user_date;not null"` + Date string `gorm:"uniqueIndex:idx_user_date;size:10;not null"` + RxBytes int64 `gorm:"default:0"` + TxBytes int64 `gorm:"default:0"` + UpdatedAt time.Time +} + +func (UserTrafficStat) TableName() string { + return "user_traffic_stats" +} diff --git a/internal/router/router.go b/internal/router/router.go index 64d8c35..3592dd6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -27,6 +27,8 @@ func Setup(r *gin.Engine) { auth.GET("/me/sessions", handler.ListMySessions) auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession) auth.GET("/me/vpn/connections", handler.GetMyVpnConnections) + auth.GET("/me/traffic/today", handler.GetMyTrafficToday) + auth.GET("/me/traffic", handler.GetMyTrafficHistory) } admin := r.Group("/api/admin") @@ -48,6 +50,10 @@ func Setup(r *gin.Engine) { admin.POST("/vpn/reservations", handler.CreateVpnReservation) admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation) admin.DELETE("/vpn/clients/:id", handler.KickUserClient) + + admin.GET("/traffic/today", handler.GetAdminTrafficToday) + admin.GET("/traffic/history", handler.GetAdminTrafficHistory) + admin.GET("/traffic/users/:id", handler.GetAdminUserTraffic) } distDir := http.Dir("./dist") diff --git a/internal/vpn/service.go b/internal/vpn/service.go index 5492a30..6fca95b 100644 --- a/internal/vpn/service.go +++ b/internal/vpn/service.go @@ -27,6 +27,7 @@ type VpnService struct { switchx *PacketSwitch tun *TUNInterface tunDone chan struct{} + flushDone chan struct{} running bool startedAt time.Time clients map[*tunnelConn]struct{} @@ -50,16 +51,59 @@ func (s *VpnService) StartedAt() time.Time { return s.startedAt } +const trafficFlushPeriod = 60 * time.Second + func (s *VpnService) TotalLiveTraffic() (rx, tx int64) { s.mu.RLock() for c := range s.clients { - rx += c.rxBytes.Load() - tx += c.txBytes.Load() + rx += c.rxBytes.Load() - c.flushedRx.Load() + tx += c.txBytes.Load() - c.flushedTx.Load() } s.mu.RUnlock() return } +func (s *VpnService) UserLiveTraffic(userID uint) (rx, tx int64) { + s.mu.RLock() + for c := range s.clients { + if c.user.ID == userID { + rx += c.rxBytes.Load() - c.flushedRx.Load() + tx += c.txBytes.Load() - c.flushedTx.Load() + } + } + s.mu.RUnlock() + return +} + +func (s *VpnService) trafficFlusher() { + ticker := time.NewTicker(trafficFlushPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.flushAllTraffic() + case <-s.flushDone: + return + } + } +} + +func (s *VpnService) flushAllTraffic() { + s.mu.RLock() + conns := make([]*tunnelConn, 0, len(s.clients)) + for c := range s.clients { + conns = append(conns, c) + } + s.mu.RUnlock() + + for _, c := range conns { + rx, tx := c.flushDelta() + if rx > 0 || tx > 0 { + recordTraffic(c.user.ID, rx, tx) + } + } +} + func (s *VpnService) Settings() model.VpnSetting { s.mu.RLock() defer s.mu.RUnlock() @@ -150,11 +194,13 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res s.switchx = NewPacketSwitch(settings.AllowClientToClient) s.tun = tun s.tunDone = make(chan struct{}) + s.flushDone = make(chan struct{}) s.running = true s.startedAt = time.Now() s.mu.Unlock() go s.serveTUN() + go s.trafficFlusher() subnet4 := ipNet.String() var subnet6Str string @@ -205,10 +251,21 @@ func (s *VpnService) Stop() error { s.running = false tun := s.tun done := s.tunDone + flushDone := s.flushDone + s.flushDone = nil clients := s.clients s.clients = make(map[*tunnelConn]struct{}) s.mu.Unlock() + if flushDone != nil { + close(flushDone) + } + for c := range clients { + rx, tx := c.flushDelta() + if rx > 0 || tx > 0 { + recordTraffic(c.user.ID, rx, tx) + } + } for c := range clients { c.close() } @@ -379,6 +436,8 @@ type ClientInfo struct { IP string `json:"ip"` IP6 string `json:"ip6,omitempty"` ConnectedAt string `json:"connected_at"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` } func (s *VpnService) KickUser(userID uint) int { diff --git a/internal/vpn/tunnel.go b/internal/vpn/tunnel.go index 5e81b84..4174c1c 100644 --- a/internal/vpn/tunnel.go +++ b/internal/vpn/tunnel.go @@ -40,11 +40,21 @@ type tunnelConn struct { ready atomic.Bool rxBytes atomic.Int64 txBytes atomic.Int64 + flushedRx atomic.Int64 + flushedTx atomic.Int64 } func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP } func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 } +func (c *tunnelConn) flushDelta() (rx, tx int64) { + curRx := c.rxBytes.Load() + curTx := c.txBytes.Load() + rx = curRx - c.flushedRx.Swap(curRx) + tx = curTx - c.flushedTx.Swap(curTx) + return +} + func (c *tunnelConn) WritePacket(data []byte) error { if !c.ready.Load() || len(data) == 0 { return nil @@ -82,6 +92,8 @@ func (c *tunnelConn) info() ClientInfo { Username: c.user.Username, IP: c.assignedIP.String(), ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"), + RxBytes: c.rxBytes.Load(), + TxBytes: c.txBytes.Load(), } if c.assignedIP6 != nil { ci.IP6 = c.assignedIP6.String() @@ -136,7 +148,8 @@ func runTunnel(conn *websocket.Conn, user *model.User) { VPN.registerClient(tc) defer func() { - recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load()) + rx, tx := tc.flushDelta() + recordTraffic(tc.user.ID, rx, tx) VPN.unregisterClient(tc) }() @@ -236,11 +249,23 @@ func runTunnel(conn *websocket.Conn, user *model.User) { } } -func recordTraffic(rx, tx int64) { +func recordTraffic(userID uint, rx, tx int64) { if rx == 0 && tx == 0 { return } today := time.Now().Format("2006-01-02") + + userStat := model.UserTrafficStat{UserID: userID, Date: today, RxBytes: rx, TxBytes: tx} + if err := db.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "rx_bytes": gorm.Expr("rx_bytes + ?", rx), + "tx_bytes": gorm.Expr("tx_bytes + ?", tx), + }), + }).Create(&userStat).Error; err != nil { + log.Printf("记录用户流量失败: %v", err) + } + stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx} if err := db.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "date"}},
{{ kickError }}
{{ t('traffic.upload') }}
{{ formatBytes(todayRx) }}
{{ t('traffic.download') }}
{{ formatBytes(todayTx) }}
{{ t('traffic.total') }}
{{ formatBytes(todayRx + todayTx) }}