mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +08:00
update: 新增api访问日志
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
### v1.3.1
|
||||
1. 添加API访问日志
|
||||
|
||||
### v1.3.0
|
||||
1. 新增 Telegram Bot
|
||||
2. 新增扩容
|
||||
|
||||
@@ -83,6 +83,9 @@ func InitDB() error {
|
||||
&entity.TaskItem{},
|
||||
&entity.File{},
|
||||
&entity.TelegramChannel{},
|
||||
&entity.APIAccessLog{},
|
||||
&entity.APIAccessLogStats{},
|
||||
&entity.APIAccessLogSummary{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
|
||||
66
db/converter/api_access_log_converter.go
Normal file
66
db/converter/api_access_log_converter.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ToAPIAccessLogResponse 将APIAccessLog实体转换为APIAccessLogResponse
|
||||
func ToAPIAccessLogResponse(log *entity.APIAccessLog) dto.APIAccessLogResponse {
|
||||
return dto.APIAccessLogResponse{
|
||||
ID: log.ID,
|
||||
IP: log.IP,
|
||||
UserAgent: log.UserAgent,
|
||||
Endpoint: log.Endpoint,
|
||||
Method: log.Method,
|
||||
RequestParams: log.RequestParams,
|
||||
ResponseStatus: log.ResponseStatus,
|
||||
ResponseData: log.ResponseData,
|
||||
ProcessCount: log.ProcessCount,
|
||||
ErrorMessage: log.ErrorMessage,
|
||||
ProcessingTime: log.ProcessingTime,
|
||||
CreatedAt: log.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIAccessLogResponseList 将APIAccessLog实体列表转换为APIAccessLogResponse列表
|
||||
func ToAPIAccessLogResponseList(logs []entity.APIAccessLog) []dto.APIAccessLogResponse {
|
||||
responses := make([]dto.APIAccessLogResponse, len(logs))
|
||||
for i, log := range logs {
|
||||
responses[i] = ToAPIAccessLogResponse(&log)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToAPIAccessLogSummaryResponse 将APIAccessLogSummary实体转换为APIAccessLogSummaryResponse
|
||||
func ToAPIAccessLogSummaryResponse(summary *entity.APIAccessLogSummary) dto.APIAccessLogSummaryResponse {
|
||||
return dto.APIAccessLogSummaryResponse{
|
||||
TotalRequests: summary.TotalRequests,
|
||||
TodayRequests: summary.TodayRequests,
|
||||
WeekRequests: summary.WeekRequests,
|
||||
MonthRequests: summary.MonthRequests,
|
||||
ErrorRequests: summary.ErrorRequests,
|
||||
UniqueIPs: summary.UniqueIPs,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIAccessLogStatsResponse 将APIAccessLogStats实体转换为APIAccessLogStatsResponse
|
||||
func ToAPIAccessLogStatsResponse(stat entity.APIAccessLogStats) dto.APIAccessLogStatsResponse {
|
||||
return dto.APIAccessLogStatsResponse{
|
||||
Endpoint: stat.Endpoint,
|
||||
Method: stat.Method,
|
||||
RequestCount: stat.RequestCount,
|
||||
ErrorCount: stat.ErrorCount,
|
||||
AvgProcessTime: stat.AvgProcessTime,
|
||||
LastAccess: stat.LastAccess,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIAccessLogStatsResponseList 将APIAccessLogStats实体列表转换为APIAccessLogStatsResponse列表
|
||||
func ToAPIAccessLogStatsResponseList(stats []entity.APIAccessLogStats) []dto.APIAccessLogStatsResponse {
|
||||
responses := make([]dto.APIAccessLogStatsResponse, len(stats))
|
||||
for i, stat := range stats {
|
||||
responses[i] = ToAPIAccessLogStatsResponse(stat)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
55
db/dto/api_access_log.go
Normal file
55
db/dto/api_access_log.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// APIAccessLogResponse API访问日志响应
|
||||
type APIAccessLogResponse struct {
|
||||
ID uint `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
RequestParams string `json:"request_params"`
|
||||
ResponseStatus int `json:"response_status"`
|
||||
ResponseData string `json:"response_data"`
|
||||
ProcessCount int `json:"process_count"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
ProcessingTime int64 `json:"processing_time"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// APIAccessLogSummaryResponse API访问日志汇总响应
|
||||
type APIAccessLogSummaryResponse struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TodayRequests int64 `json:"today_requests"`
|
||||
WeekRequests int64 `json:"week_requests"`
|
||||
MonthRequests int64 `json:"month_requests"`
|
||||
ErrorRequests int64 `json:"error_requests"`
|
||||
UniqueIPs int64 `json:"unique_ips"`
|
||||
}
|
||||
|
||||
// APIAccessLogStatsResponse 按端点统计响应
|
||||
type APIAccessLogStatsResponse struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
AvgProcessTime int64 `json:"avg_process_time"`
|
||||
LastAccess time.Time `json:"last_access"`
|
||||
}
|
||||
|
||||
// APIAccessLogListResponse API访问日志列表响应
|
||||
type APIAccessLogListResponse struct {
|
||||
Data []APIAccessLogResponse `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// APIAccessLogFilterRequest API访问日志过滤请求
|
||||
type APIAccessLogFilterRequest struct {
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Page int `json:"page,omitempty" default:"1"`
|
||||
PageSize int `json:"page_size,omitempty" default:"20"`
|
||||
}
|
||||
50
db/entity/api_access_log.go
Normal file
50
db/entity/api_access_log.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIAccessLog API访问日志模型
|
||||
type APIAccessLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
IP string `json:"ip" gorm:"size:45;not null;comment:客户端IP地址"`
|
||||
UserAgent string `json:"user_agent" gorm:"size:500;comment:用户代理"`
|
||||
Endpoint string `json:"endpoint" gorm:"size:255;not null;comment:访问的接口路径"`
|
||||
Method string `json:"method" gorm:"size:10;not null;comment:HTTP方法"`
|
||||
RequestParams string `json:"request_params" gorm:"type:text;comment:查询参数(JSON格式)"`
|
||||
ResponseStatus int `json:"response_status" gorm:"default:200;comment:响应状态码"`
|
||||
ResponseData string `json:"response_data" gorm:"type:text;comment:响应数据(JSON格式)"`
|
||||
ProcessCount int `json:"process_count" gorm:"default:0;comment:处理数量(查询结果数或添加的数量)"`
|
||||
ErrorMessage string `json:"error_message" gorm:"size:500;comment:错误消息"`
|
||||
ProcessingTime int64 `json:"processing_time" gorm:"comment:处理时间(毫秒)"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (APIAccessLog) TableName() string {
|
||||
return "api_access_logs"
|
||||
}
|
||||
|
||||
// APIAccessLogSummary API访问日志汇总统计
|
||||
type APIAccessLogSummary struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TodayRequests int64 `json:"today_requests"`
|
||||
WeekRequests int64 `json:"week_requests"`
|
||||
MonthRequests int64 `json:"month_requests"`
|
||||
ErrorRequests int64 `json:"error_requests"`
|
||||
UniqueIPs int64 `json:"unique_ips"`
|
||||
}
|
||||
|
||||
// APIAccessLogStats 按端点统计
|
||||
type APIAccessLogStats struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
AvgProcessTime int64 `json:"avg_process_time"`
|
||||
LastAccess time.Time `json:"last_access"`
|
||||
}
|
||||
169
db/repo/api_access_log_repository.go
Normal file
169
db/repo/api_access_log_repository.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIAccessLogRepository API访问日志Repository接口
|
||||
type APIAccessLogRepository interface {
|
||||
BaseRepository[entity.APIAccessLog]
|
||||
RecordAccess(ip, userAgent, endpoint, method string, requestParams interface{}, responseStatus int, responseData interface{}, processCount int, errorMessage string, processingTime int64) error
|
||||
GetSummary() (*entity.APIAccessLogSummary, error)
|
||||
GetStatsByEndpoint() ([]entity.APIAccessLogStats, error)
|
||||
FindWithFilters(page, limit int, startDate, endDate *time.Time, endpoint, ip string) ([]entity.APIAccessLog, int64, error)
|
||||
ClearOldLogs(days int) error
|
||||
}
|
||||
|
||||
// APIAccessLogRepositoryImpl API访问日志Repository实现
|
||||
type APIAccessLogRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.APIAccessLog]
|
||||
}
|
||||
|
||||
// NewAPIAccessLogRepository 创建API访问日志Repository
|
||||
func NewAPIAccessLogRepository(db *gorm.DB) APIAccessLogRepository {
|
||||
return &APIAccessLogRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.APIAccessLog]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// RecordAccess 记录API访问
|
||||
func (r *APIAccessLogRepositoryImpl) RecordAccess(ip, userAgent, endpoint, method string, requestParams interface{}, responseStatus int, responseData interface{}, processCount int, errorMessage string, processingTime int64) error {
|
||||
log := entity.APIAccessLog{
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Endpoint: endpoint,
|
||||
Method: method,
|
||||
ResponseStatus: responseStatus,
|
||||
ProcessCount: processCount,
|
||||
ErrorMessage: errorMessage,
|
||||
ProcessingTime: processingTime,
|
||||
}
|
||||
|
||||
// 序列化请求参数
|
||||
if requestParams != nil {
|
||||
if paramsJSON, err := json.Marshal(requestParams); err == nil {
|
||||
log.RequestParams = string(paramsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化响应数据(限制大小,避免存储大量数据)
|
||||
if responseData != nil {
|
||||
if dataJSON, err := json.Marshal(responseData); err == nil {
|
||||
// 限制响应数据长度,避免存储过多数据
|
||||
dataStr := string(dataJSON)
|
||||
if len(dataStr) > 2000 {
|
||||
dataStr = dataStr[:2000] + "..."
|
||||
}
|
||||
log.ResponseData = dataStr
|
||||
}
|
||||
}
|
||||
|
||||
return r.db.Create(&log).Error
|
||||
}
|
||||
|
||||
// GetSummary 获取访问日志汇总
|
||||
func (r *APIAccessLogRepositoryImpl) GetSummary() (*entity.APIAccessLogSummary, error) {
|
||||
var summary entity.APIAccessLogSummary
|
||||
now := utils.GetCurrentTime()
|
||||
todayStr := now.Format(utils.TimeFormatDate)
|
||||
weekStart := now.AddDate(0, 0, -int(now.Weekday())+1).Format(utils.TimeFormatDate)
|
||||
monthStart := now.Format("2006-01") + "-01"
|
||||
|
||||
// 总请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Count(&summary.TotalRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 今日请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("DATE(created_at) = ?", todayStr).Count(&summary.TodayRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 本周请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("created_at >= ?", weekStart).Count(&summary.WeekRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 本月请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("created_at >= ?", monthStart).Count(&summary.MonthRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 错误请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("response_status >= 400").Count(&summary.ErrorRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 唯一IP数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Distinct("ip").Count(&summary.UniqueIPs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// GetStatsByEndpoint 按端点获取统计
|
||||
func (r *APIAccessLogRepositoryImpl) GetStatsByEndpoint() ([]entity.APIAccessLogStats, error) {
|
||||
var stats []entity.APIAccessLogStats
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
endpoint,
|
||||
method,
|
||||
COUNT(*) as request_count,
|
||||
SUM(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) as error_count,
|
||||
AVG(processing_time) as avg_process_time,
|
||||
MAX(created_at) as last_access
|
||||
FROM api_access_logs
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY endpoint, method
|
||||
ORDER BY request_count DESC
|
||||
`
|
||||
|
||||
err := r.db.Raw(query).Scan(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// FindWithFilters 带过滤条件的分页查找访问日志
|
||||
func (r *APIAccessLogRepositoryImpl) FindWithFilters(page, limit int, startDate, endDate *time.Time, endpoint, ip string) ([]entity.APIAccessLog, int64, error) {
|
||||
var logs []entity.APIAccessLog
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
query := r.db.Model(&entity.APIAccessLog{})
|
||||
|
||||
// 添加过滤条件
|
||||
if startDate != nil {
|
||||
query = query.Where("created_at >= ?", *startDate)
|
||||
}
|
||||
if endDate != nil {
|
||||
query = query.Where("created_at <= ?", *endDate)
|
||||
}
|
||||
if endpoint != "" {
|
||||
query = query.Where("endpoint LIKE ?", "%"+endpoint+"%")
|
||||
}
|
||||
if ip != "" {
|
||||
query = query.Where("ip = ?", ip)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据,按创建时间倒序排列
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
// ClearOldLogs 清理旧日志
|
||||
func (r *APIAccessLogRepositoryImpl) ClearOldLogs(days int) error {
|
||||
cutoffDate := utils.GetCurrentTime().AddDate(0, 0, -days)
|
||||
return r.db.Where("created_at < ?", cutoffDate).Delete(&entity.APIAccessLog{}).Error
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type RepositoryManager struct {
|
||||
TaskItemRepository TaskItemRepository
|
||||
FileRepository FileRepository
|
||||
TelegramChannelRepository TelegramChannelRepository
|
||||
APIAccessLogRepository APIAccessLogRepository
|
||||
}
|
||||
|
||||
// NewRepositoryManager 创建Repository管理器
|
||||
@@ -41,5 +42,6 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
||||
TaskItemRepository: NewTaskItemRepository(db),
|
||||
FileRepository: NewFileRepository(db),
|
||||
TelegramChannelRepository: NewTelegramChannelRepository(db),
|
||||
APIAccessLogRepository: NewAPIAccessLogRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
100
handlers/api_access_log_handler.go
Normal file
100
handlers/api_access_log_handler.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAPIAccessLogs 获取API访问日志
|
||||
func GetAPIAccessLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
startDateStr := c.Query("start_date")
|
||||
endDateStr := c.Query("end_date")
|
||||
endpoint := c.Query("endpoint")
|
||||
ip := c.Query("ip")
|
||||
|
||||
var startDate, endDate *time.Time
|
||||
|
||||
if startDateStr != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||
startDate = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
if endDateStr != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", endDateStr); err == nil {
|
||||
// 设置为当天结束时间
|
||||
endOfDay := parsed.Add(24*time.Hour - time.Second)
|
||||
endDate = &endOfDay
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
logs, total, err := repoManager.APIAccessLogRepository.FindWithFilters(page, pageSize, startDate, endDate, endpoint, ip)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取API访问日志失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ToAPIAccessLogResponseList(logs)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": response,
|
||||
"total": int(total),
|
||||
"page": page,
|
||||
"limit": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAPIAccessLogSummary 获取API访问日志汇总
|
||||
func GetAPIAccessLogSummary(c *gin.Context) {
|
||||
summary, err := repoManager.APIAccessLogRepository.GetSummary()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取API访问日志汇总失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ToAPIAccessLogSummaryResponse(summary)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetAPIAccessLogStats 获取API访问日志统计
|
||||
func GetAPIAccessLogStats(c *gin.Context) {
|
||||
stats, err := repoManager.APIAccessLogRepository.GetStatsByEndpoint()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取API访问日志统计失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ToAPIAccessLogStatsResponseList(stats)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// ClearAPIAccessLogs 清理API访问日志
|
||||
func ClearAPIAccessLogs(c *gin.Context) {
|
||||
daysStr := c.Query("days")
|
||||
if daysStr == "" {
|
||||
ErrorResponse(c, "请提供要清理的天数参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(daysStr)
|
||||
if err != nil || days < 1 {
|
||||
ErrorResponse(c, "无效的天数参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = repoManager.APIAccessLogRepository.ClearOldLogs(days)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "清理API访问日志失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"message": "API访问日志清理成功"})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
@@ -69,6 +70,53 @@ func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]
|
||||
return filteredResources, uniqueForbiddenWords
|
||||
}
|
||||
|
||||
// logAPIAccess 记录API访问日志
|
||||
func (h *PublicAPIHandler) logAPIAccess(c *gin.Context, startTime time.Time, processCount int, responseData interface{}, errorMessage string) {
|
||||
endpoint := c.Request.URL.Path
|
||||
method := c.Request.Method
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// 计算处理时间
|
||||
processingTime := time.Since(startTime).Milliseconds()
|
||||
|
||||
// 获取查询参数
|
||||
var requestParams interface{}
|
||||
if method == "GET" {
|
||||
requestParams = c.Request.URL.Query()
|
||||
} else {
|
||||
// 对于POST请求,尝试从上下文中获取请求体(如果之前已解析)
|
||||
if req, exists := c.Get("request_body"); exists {
|
||||
requestParams = req
|
||||
}
|
||||
}
|
||||
|
||||
// 异步记录日志,避免影响API响应时间
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("记录API访问日志时发生panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := repoManager.APIAccessLogRepository.RecordAccess(
|
||||
ip,
|
||||
userAgent,
|
||||
endpoint,
|
||||
method,
|
||||
requestParams,
|
||||
c.Writer.Status(),
|
||||
responseData,
|
||||
processCount,
|
||||
errorMessage,
|
||||
processingTime,
|
||||
)
|
||||
if err != nil {
|
||||
utils.Error("记录API访问日志失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AddBatchResources godoc
|
||||
// @Summary 批量添加资源
|
||||
// @Description 通过公开API批量添加多个资源到待处理列表
|
||||
@@ -83,12 +131,18 @@ func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/resources/batch-add [post]
|
||||
func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
var req dto.BatchReadyResourceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logAPIAccess(c, startTime, 0, nil, "请求参数错误: "+err.Error())
|
||||
ErrorResponse(c, "请求参数错误: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
// 存储请求体用于日志记录
|
||||
c.Set("request_body", req)
|
||||
|
||||
if len(req.Resources) == 0 {
|
||||
ErrorResponse(c, "资源列表不能为空", 400)
|
||||
return
|
||||
@@ -125,6 +179,7 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
// 生成 key(每组同一个 key)
|
||||
key, err := repoManager.ReadyResourceRepository.GenerateUniqueKey()
|
||||
if err != nil {
|
||||
h.logAPIAccess(c, startTime, len(createdResources), nil, "生成资源组标识失败: "+err.Error())
|
||||
ErrorResponse(c, "生成资源组标识失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
@@ -156,10 +211,12 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
responseData := gin.H{
|
||||
"created_count": len(createdResources),
|
||||
"created_ids": createdResources,
|
||||
})
|
||||
}
|
||||
h.logAPIAccess(c, startTime, len(createdResources), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// SearchResources godoc
|
||||
@@ -179,6 +236,8 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/resources/search [get]
|
||||
func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
// 获取查询参数
|
||||
keyword := c.Query("keyword")
|
||||
tag := c.Query("tag")
|
||||
@@ -276,6 +335,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
// 执行数据库搜索
|
||||
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
h.logAPIAccess(c, startTime, 0, nil, "搜索失败: "+err.Error())
|
||||
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
@@ -320,6 +380,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
"limit": pageSize,
|
||||
}
|
||||
|
||||
h.logAPIAccess(c, startTime, len(resourceResponses), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
@@ -337,6 +398,8 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/hot-dramas [get]
|
||||
func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "20")
|
||||
|
||||
@@ -353,6 +416,7 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
// 获取热门剧
|
||||
hotDramas, total, err := repoManager.HotDramaRepository.FindAll(page, pageSize)
|
||||
if err != nil {
|
||||
h.logAPIAccess(c, startTime, 0, nil, "获取热门剧失败: "+err.Error())
|
||||
ErrorResponse(c, "获取热门剧失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
@@ -376,10 +440,12 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
responseData := gin.H{
|
||||
"hot_dramas": hotDramaResponses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
h.logAPIAccess(c, startTime, len(hotDramaResponses), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
6
main.go
6
main.go
@@ -272,6 +272,12 @@ func main() {
|
||||
api.POST("/search-stats/record", handlers.RecordSearch)
|
||||
api.GET("/search-stats/summary", handlers.GetSearchStatsSummary)
|
||||
|
||||
// API访问日志路由
|
||||
api.GET("/api-access-logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetAPIAccessLogs)
|
||||
api.GET("/api-access-logs/summary", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetAPIAccessLogSummary)
|
||||
api.GET("/api-access-logs/stats", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetAPIAccessLogStats)
|
||||
api.DELETE("/api-access-logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearAPIAccessLogs)
|
||||
|
||||
// 系统配置路由
|
||||
api.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
|
||||
api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig)
|
||||
|
||||
@@ -326,4 +326,18 @@ export const useMeilisearchApi = () => {
|
||||
getSyncProgress,
|
||||
debugGetAllDocuments
|
||||
}
|
||||
}
|
||||
|
||||
// API访问日志管理API
|
||||
export const useApiAccessLogApi = () => {
|
||||
const getApiAccessLogs = (params?: any) => useApiFetch('/api/api-access-logs', { params }).then(parseApiResponse)
|
||||
const getApiAccessLogSummary = () => useApiFetch('/api/api-access-logs/summary').then(parseApiResponse)
|
||||
const getApiAccessLogStats = () => useApiFetch('/api/api-access-logs/stats').then(parseApiResponse)
|
||||
const clearApiAccessLogs = (days: number) => useApiFetch('/api/api-access-logs', { method: 'DELETE', body: { days } }).then(parseApiResponse)
|
||||
return {
|
||||
getApiAccessLogs,
|
||||
getApiAccessLogSummary,
|
||||
getApiAccessLogStats,
|
||||
clearApiAccessLogs
|
||||
}
|
||||
}
|
||||
@@ -398,13 +398,13 @@ const userMenuItems = computed(() => [
|
||||
{
|
||||
to: '/admin/accounts',
|
||||
icon: 'fas fa-user-shield',
|
||||
label: '账号管理',
|
||||
label: '平台账号',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
to: '/admin/system-config',
|
||||
icon: 'fas fa-cog',
|
||||
label: '系统配置',
|
||||
to: '/admin/api-access-logs',
|
||||
icon: 'fas fa-history',
|
||||
label: 'API访问日志',
|
||||
type: 'link'
|
||||
},
|
||||
{
|
||||
|
||||
387
web/pages/admin/api-access-logs.vue
Normal file
387
web/pages/admin/api-access-logs.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<AdminPageLayout>
|
||||
<!-- 页面头部 - 标题和按钮 -->
|
||||
<template #page-header>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">公开API访问日志</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">查看公开API的访问记录和统计信息</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<n-button type="primary" @click="refreshData" :loading="loading">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
<n-button type="warning" @click="clearOldLogs" :loading="clearing">
|
||||
<template #icon>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</template>
|
||||
清理旧日志
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 过滤栏 - 搜索和筛选 -->
|
||||
<template #filter-bar>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索接口路径或IP..."
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-date-picker
|
||||
v-model:value="startDate"
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-date-picker
|
||||
v-model:value="endDate"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区header - 统计信息 -->
|
||||
<template #content-header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-lg font-semibold">访问日志列表</span>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {{ total }} 条日志
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="flex space-x-6">
|
||||
<div class="text-center flex items-base">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ summary.total_requests }}</div>
|
||||
<div class="text-xs text-gray-500">总请求</div>
|
||||
</div>
|
||||
<div class="text-center flex items-base">
|
||||
<div class="text-2xl font-bold text-green-600">{{ summary.today_requests }}</div>
|
||||
<div class="text-xs text-gray-500">今日请求</div>
|
||||
</div>
|
||||
<div class="text-center flex items-base">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ summary.week_requests }}</div>
|
||||
<div class="text-xs text-gray-500">本周请求</div>
|
||||
</div>
|
||||
<div class="text-center flex items-base">
|
||||
<div class="text-2xl font-bold text-red-600">{{ summary.error_requests }}</div>
|
||||
<div class="text-xs text-gray-500">错误请求</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区content - 日志列表 -->
|
||||
<template #content>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="logs.length === 0" class="flex flex-col items-center justify-center py-12">
|
||||
<i class="fas fa-file-alt text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500 dark:text-gray-400">暂无访问日志</p>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-3 mb-2 w-full">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ log.id }}</span>
|
||||
<n-tag :type="getMethodTagType(log.method)" size="small">
|
||||
{{ log.method }}
|
||||
</n-tag>
|
||||
<n-tag :type="getStatusTagType(log.response_status)" size="small">
|
||||
{{ log.response_status }}
|
||||
</n-tag>
|
||||
<code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
{{ log.endpoint }}
|
||||
</code>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 float-right">
|
||||
<code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
{{ log.ip }}
|
||||
</code>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ formatDate(log.created_at) }}
|
||||
</span>
|
||||
<span v-if="log.processing_time > 0" class="flex items-center">
|
||||
<i class="fas fa-tachometer-alt mr-1"></i>
|
||||
{{ log.processing_time }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="log.request_params" class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<strong>请求参数:</strong> {{ log.request_params }}
|
||||
</div>
|
||||
|
||||
<div v-if="log.error_message" class="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
<strong>错误信息:</strong> {{ log.error_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区footer - 分页组件 -->
|
||||
<template #content-footer>
|
||||
<div class="p-4">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:item-count="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
show-size-picker
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AdminPageLayout>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面布局
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
ssr: false
|
||||
})
|
||||
|
||||
import { useApiAccessLogApi } from '~/composables/useApi'
|
||||
|
||||
const notification = useNotification()
|
||||
const dialog = useDialog()
|
||||
|
||||
interface ApiAccessLog {
|
||||
id: number
|
||||
ip: string
|
||||
user_agent: string
|
||||
endpoint: string
|
||||
method: string
|
||||
request_params: string
|
||||
response_status: number
|
||||
response_data: string
|
||||
process_count: number
|
||||
error_message: string
|
||||
processing_time: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 获取API实例
|
||||
const apiAccessLogApi = useApiAccessLogApi()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const clearing = ref(false)
|
||||
const logs = ref<ApiAccessLog[]>([])
|
||||
const summary = ref({
|
||||
total_requests: 0,
|
||||
today_requests: 0,
|
||||
week_requests: 0,
|
||||
month_requests: 0,
|
||||
error_requests: 0,
|
||||
unique_ips: 0
|
||||
})
|
||||
|
||||
// 筛选和搜索
|
||||
const searchQuery = ref('')
|
||||
const startDate = ref<number | null>(null)
|
||||
const endDate = ref<number | null>(null)
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
|
||||
// 获取日志数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
}
|
||||
|
||||
// 添加日期筛选
|
||||
if (startDate.value) {
|
||||
const date = new Date(startDate.value)
|
||||
params.start_date = date.toISOString().split('T')[0]
|
||||
}
|
||||
if (endDate.value) {
|
||||
const date = new Date(endDate.value)
|
||||
params.end_date = date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// 添加搜索条件
|
||||
if (searchQuery.value) {
|
||||
params.endpoint = searchQuery.value
|
||||
params.ip = searchQuery.value
|
||||
}
|
||||
|
||||
const response = await apiAccessLogApi.getApiAccessLogs(params) as any
|
||||
logs.value = response.data || []
|
||||
total.value = response.total || 0
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取API访问日志失败:', error)
|
||||
notification.error({
|
||||
content: '获取API访问日志失败',
|
||||
duration: 3000
|
||||
})
|
||||
logs.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计汇总
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const response = await apiAccessLogApi.getApiAccessLogSummary()
|
||||
summary.value = response as any
|
||||
} catch (error) {
|
||||
console.error('获取统计汇总失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
fetchSummary()
|
||||
}
|
||||
|
||||
// 方法标签类型
|
||||
const getMethodTagType = (method: string): 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error' => {
|
||||
const methodColors: Record<string, 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
GET: 'info',
|
||||
POST: 'success',
|
||||
PUT: 'warning',
|
||||
DELETE: 'error',
|
||||
PATCH: 'warning'
|
||||
}
|
||||
return methodColors[method] || 'default'
|
||||
}
|
||||
|
||||
// 状态标签类型
|
||||
const getStatusTagType = (status: number) => {
|
||||
if (status >= 200 && status < 300) return 'success'
|
||||
if (status >= 400 && status < 500) return 'warning'
|
||||
if (status >= 500) return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 清理旧日志
|
||||
const clearOldLogs = async () => {
|
||||
dialog.warning({
|
||||
title: '清理旧日志',
|
||||
content: '确定要清理30天前的旧日志吗?此操作不可恢复。',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
clearing.value = true
|
||||
await apiAccessLogApi.clearApiAccessLogs(30)
|
||||
|
||||
notification.success({
|
||||
content: '旧日志清理成功',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
refreshData()
|
||||
} catch (error) {
|
||||
console.error('清理旧日志失败:', error)
|
||||
notification.error({
|
||||
content: '清理旧日志失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchData(), fetchSummary()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-content {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-white, #ffffff);
|
||||
}
|
||||
|
||||
.dark .logs-content {
|
||||
background-color: var(--color-dark-bg, #1f2937);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user