// 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 天流量采样 }