update: 新增api访问日志

This commit is contained in:
ctwj
2025-10-07 02:30:01 +08:00
parent 10294e093f
commit 51dbf0f03a
13 changed files with 929 additions and 8 deletions

View File

@@ -1,3 +1,6 @@
### v1.3.1
1. 添加API访问日志
### v1.3.0
1. 新增 Telegram Bot
2. 新增扩容

View File

@@ -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)

View 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
View 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"`
}

View 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"`
}

View 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
}

View File

@@ -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),
}
}

View 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访问日志清理成功"})
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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'
},
{

View 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>