Files
opsx-vpn/backend/internal/api/logapi.go
T
Codex 76bfda8627 docs+feat: record backlog tip and move custom protocols to More
Document delivered IA and backlog four items; put custom plugins under
the wizard More group per design.
2026-10-23 16:17:43 +08:00

301 lines
8.7 KiB
Go

// Package api 日志管理 API:审计日志/连接日志的分页、筛选、导出、清理
package api
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
// ---- 审计日志 ----
// listAuditPaged 分页查询审计日志(支持筛选)
// GET /api/audit-logs?page=1&page_size=20&username=xxx&action=xxx&keyword=xxx&start=2026-01-01&end=2026-12-31
func (s *Server) listAuditPaged(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := store.DB.Model(&model.AuditLog{})
if v := c.Query("username"); v != "" {
q = q.Where("username = ?", v)
}
if v := c.Query("action"); v != "" {
q = q.Where("action LIKE ?", "%"+v+"%")
}
if v := c.Query("keyword"); v != "" {
q = q.Where("(target LIKE ? OR detail LIKE ?)", "%"+v+"%", "%"+v+"%")
}
if v := c.Query("start"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at >= ?", t)
}
}
if v := c.Query("end"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at < ?", t.Add(24*time.Hour))
}
}
var total int64
q.Count(&total)
var logs []model.AuditLog
q.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs)
c.JSON(200, gin.H{
"items": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// exportAuditLogs 导出审计日志为 CSV
// GET /api/audit-logs/export?username=xxx&action=xxx&start=xxx&end=xxx
func (s *Server) exportAuditLogs(c *gin.Context) {
q := store.DB.Model(&model.AuditLog{})
if v := c.Query("username"); v != "" {
q = q.Where("username = ?", v)
}
if v := c.Query("action"); v != "" {
q = q.Where("action LIKE ?", "%"+v+"%")
}
if v := c.Query("keyword"); v != "" {
q = q.Where("(target LIKE ? OR detail LIKE ?)", "%"+v+"%", "%"+v+"%")
}
if v := c.Query("start"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at >= ?", t)
}
}
if v := c.Query("end"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at < ?", t.Add(24*time.Hour))
}
}
var logs []model.AuditLog
q.Order("id desc").Limit(10000).Find(&logs)
filename := fmt.Sprintf("audit-logs-%s.csv", time.Now().Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM
w := csv.NewWriter(c.Writer)
w.Write([]string{"ID", "时间", "用户", "操作", "目标", "详情"})
for _, l := range logs {
w.Write([]string{
strconv.FormatUint(uint64(l.ID), 10),
l.CreatedAt.Format("2006-01-02 15:04:05"),
l.Username,
l.Action,
l.Target,
l.Detail,
})
}
w.Flush()
}
// ---- 连接日志 ----
// listConnectionLogsPaged 分页查询连接日志
// GET /api/connection-logs?page=1&page_size=20&username=xxx&event=xxx&tunnel_id=xxx&start=xxx&end=xxx
func (s *Server) listConnectionLogsPaged(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := store.DB.Model(&model.ConnectionLog{})
if v := c.Query("username"); v != "" {
q = q.Where("username = ?", v)
}
if v := c.Query("event"); v != "" {
events := strings.Split(v, ",")
q = q.Where("event IN ?", events)
}
if v := c.Query("tunnel_id"); v != "" {
if tid, err := strconv.Atoi(v); err == nil {
q = q.Where("tunnel_id = ?", tid)
}
}
if v := c.Query("keyword"); v != "" {
q = q.Where("(detail LIKE ? OR ip_address LIKE ?)", "%"+v+"%", "%"+v+"%")
}
if v := c.Query("start"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at >= ?", t)
}
}
if v := c.Query("end"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at < ?", t.Add(24*time.Hour))
}
}
var total int64
q.Count(&total)
var logs []model.ConnectionLog
q.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs)
c.JSON(200, gin.H{
"items": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// exportConnectionLogs 导出连接日志为 CSV
func (s *Server) exportConnectionLogs(c *gin.Context) {
q := store.DB.Model(&model.ConnectionLog{})
if v := c.Query("username"); v != "" {
q = q.Where("username = ?", v)
}
if v := c.Query("event"); v != "" {
events := strings.Split(v, ",")
q = q.Where("event IN ?", events)
}
if v := c.Query("tunnel_id"); v != "" {
if tid, err := strconv.Atoi(v); err == nil {
q = q.Where("tunnel_id = ?", tid)
}
}
if v := c.Query("keyword"); v != "" {
q = q.Where("(detail LIKE ? OR ip_address LIKE ?)", "%"+v+"%", "%"+v+"%")
}
if v := c.Query("start"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at >= ?", t)
}
}
if v := c.Query("end"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
q = q.Where("created_at < ?", t.Add(24*time.Hour))
}
}
var logs []model.ConnectionLog
q.Order("id desc").Limit(10000).Find(&logs)
filename := fmt.Sprintf("connection-logs-%s.csv", time.Now().Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
w := csv.NewWriter(c.Writer)
w.Write([]string{"ID", "时间", "隧道ID", "用户名", "事件", "IP地址", "详情"})
for _, l := range logs {
w.Write([]string{
strconv.FormatUint(uint64(l.ID), 10),
l.CreatedAt.Format("2006-01-02 15:04:05"),
strconv.FormatUint(uint64(l.TunnelID), 10),
l.Username,
l.Event,
l.IPAddress,
l.Detail,
})
}
w.Flush()
}
// ---- 日志清理 ----
// cleanupLogs 清理过期日志
// POST /api/logs/cleanup {"days": 30}
func (s *Server) cleanupLogs(c *gin.Context) {
var req struct {
Days int `json:"days"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Days < 1 {
req.Days = 30
}
cutoff := time.Now().AddDate(0, 0, -req.Days)
var auditDeleted, connDeleted, sessionDeleted int64
store.DB.Where("created_at < ?", cutoff).Delete(&model.AuditLog{}).Count(&auditDeleted)
store.DB.Where("created_at < ?", cutoff).Delete(&model.ConnectionLog{}).Count(&connDeleted)
store.DB.Where("connected_at < ? AND status = ?", cutoff, "disconnected").Delete(&model.VPNSession{}).Count(&sessionDeleted)
store.Audit(c.GetString("username"), "cleanup_logs", "", fmt.Sprintf("清理 %d 天前日志: 审计=%d 连接=%d 会话=%d", req.Days, auditDeleted, connDeleted, sessionDeleted))
c.JSON(200, gin.H{
"ok": true,
"audit_deleted": auditDeleted,
"conn_deleted": connDeleted,
"session_deleted": sessionDeleted,
"cutoff": cutoff.Format("2006-01-02"),
})
}
// logStats 日志统计(各表记录数 + 最早/最新记录时间)
// GET /api/logs/stats
func (s *Server) logStats(c *gin.Context) {
type stat struct {
Count int64 `json:"count"`
Oldest string `json:"oldest"`
Newest string `json:"newest"`
}
getStat := func(modelName interface{}) stat {
var st stat
store.DB.Model(modelName).Count(&st.Count)
// SQLite 的 MIN/MAX 返回字符串,直接扫 string 更兼容
store.DB.Model(modelName).Select("COALESCE(MIN(created_at),'')").Scan(&st.Oldest)
store.DB.Model(modelName).Select("COALESCE(MAX(created_at),'')").Scan(&st.Newest)
return st
}
// vpn_sessions 用 connected_at
getSessionStat := func() stat {
var st stat
store.DB.Model(&model.VPNSession{}).Count(&st.Count)
store.DB.Model(&model.VPNSession{}).Select("COALESCE(MIN(connected_at),'')").Scan(&st.Oldest)
store.DB.Model(&model.VPNSession{}).Select("COALESCE(MAX(connected_at),'')").Scan(&st.Newest)
return st
}
c.JSON(200, gin.H{
"audit": getStat(&model.AuditLog{}),
"connection": getStat(&model.ConnectionLog{}),
"session": getSessionStat(),
})
}
// ---- 自动清理(启动时调用)----
// AutoCleanupLogs 自动清理超过 keepDays 天的旧日志(0 = 不清理)
func AutoCleanupLogs(keepDays int) {
if keepDays <= 0 || store.DB == nil {
return
}
cutoff := time.Now().AddDate(0, 0, -keepDays)
store.DB.Where("created_at < ?", cutoff).Delete(&model.AuditLog{})
store.DB.Where("created_at < ?", cutoff).Delete(&model.ConnectionLog{})
store.DB.Where("connected_at < ? AND status = ?", cutoff, "disconnected").Delete(&model.VPNSession{})
store.DB.Where("created_at < ?", cutoff).Delete(&model.TrafficSample{}) // 保留最近 30 天流量采样
}