feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署

UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
// Package sessionmgr 管理 VPN 在线会话:同步运行时 peers → VPNSession 表,
// 提供在线列表查询和踢下线能力。跨协议统一(WireGuard peers / OpenVPN clients)。
package sessionmgr
import (
"fmt"
"time"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
// ClientInfo 协议层上报的在线客户端信息(统一格式)
type ClientInfo struct {
Username string // 用户名或证书 CN
RealAddress string // 客户端公网地址 IP:Port
VirtualAddress string // 分配的 VPN IP
RxBytes int64
TxBytes int64
}
// SyncSessions 将运行时客户端列表同步到 VPNSession 表。
// 新连接 → 创建 active 记录 + 写 ConnectionLog(connect);
// 已不在列表中的 active 会话 → 标记 disconnected + 写 ConnectionLog(disconnect)。
func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
if store.DB == nil {
return
}
now := time.Now()
// 1. 收集当前在线的标识 key(username|realAddress)
currentKeys := map[string]bool{}
for _, c := range clients {
key := c.Username + "|" + c.RealAddress
currentKeys[key] = true
// 查是否已有 active 会话
var existing model.VPNSession
err := store.DB.Where("tunnel_id = ? AND username = ? AND real_address = ? AND status = ?",
tunnelID, c.Username, c.RealAddress, "active").First(&existing).Error
if err == nil {
// 已有会话 → 更新流量
store.DB.Model(&existing).Updates(map[string]interface{}{
"rx_bytes": c.RxBytes, "tx_bytes": c.TxBytes,
})
} else {
// 新连接 → 创建会话 + 审计日志
session := model.VPNSession{
TunnelID: tunnelID,
Username: c.Username,
Protocol: protocol,
RealAddress: c.RealAddress,
VirtualAddress: c.VirtualAddress,
ConnectedAt: now,
RxBytes: c.RxBytes,
TxBytes: c.TxBytes,
Status: "active",
}
// 尝试关联 VPNUser
var vu model.VPNUser
if store.DB.Where("tunnel_id = ? AND username = ?", tunnelID, c.Username).First(&vu).Error == nil {
session.VPNUserID = vu.ID
}
store.DB.Create(&session)
LogConnection(tunnelID, c.Username, "connect", c.RealAddress, "")
}
}
// 2. 标记已不在列表中的 active 会话为 disconnected
var activeSessions []model.VPNSession
store.DB.Where("tunnel_id = ? AND status = ?", tunnelID, "active").Find(&activeSessions)
for _, s := range activeSessions {
key := s.Username + "|" + s.RealAddress
if !currentKeys[key] {
store.DB.Model(&s).Updates(map[string]interface{}{
"status": "disconnected",
"disconnected_at": now,
})
duration := now.Sub(s.ConnectedAt).Round(time.Second)
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress,
fmt.Sprintf("时长 %s, ↓%s ↑%s", duration, fmtBytes(s.RxBytes), fmtBytes(s.TxBytes)))
}
}
// 3. 如果 clients 为空(隧道无在线用户),标记该隧道所有 active 会话为 disconnected
if len(clients) == 0 {
for _, s := range activeSessions {
store.DB.Model(&s).Updates(map[string]interface{}{
"status": "disconnected",
"disconnected_at": now,
})
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress, "隧道无在线用户")
}
}
}
// SyncFromPeers 从 core.Status.Peers 同步(WireGuard 用)
func SyncFromPeers(tunnelID uint, protocol string, peers []core.PeerStatus) {
clients := make([]ClientInfo, 0, len(peers))
for _, p := range peers {
if !p.Connected {
continue
}
clients = append(clients, ClientInfo{
Username: p.Name, // WG peer 名称
RealAddress: p.Endpoint,
RxBytes: p.RxBytes,
TxBytes: p.TxBytes,
})
}
SyncSessions(tunnelID, protocol, clients)
}
// ListActive 查询所有在线会话
func ListActive() []model.VPNSession {
var sessions []model.VPNSession
store.DB.Where("status = ?", "active").Order("connected_at desc").Find(&sessions)
return sessions
}
// CountActive 统计在线会话数
func CountActive() int64 {
var count int64
store.DB.Model(&model.VPNSession{}).Where("status = ?", "active").Count(&count)
return count
}
// LogConnection 写连接审计日志
func LogConnection(tunnelID uint, username, event, ip, detail string) {
if store.DB == nil {
return
}
store.DB.Create(&model.ConnectionLog{
TunnelID: tunnelID,
Username: username,
Event: event,
IPAddress: ip,
Detail: detail,
})
}
// ListLogs 查询连接审计日志
func ListLogs(limit int) []model.ConnectionLog {
var logs []model.ConnectionLog
if limit <= 0 {
limit = 200
}
store.DB.Order("id desc").Limit(limit).Find(&logs)
return logs
}
func fmtBytes(n int64) string {
if n < 1024 {
return fmt.Sprintf("%dB", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1fKB", float64(n)/1024)
}
if n < 1024*1024*1024 {
return fmt.Sprintf("%.1fMB", float64(n)/(1024*1024))
}
return fmt.Sprintf("%.1fGB", float64(n)/(1024*1024*1024))
}