数据结构优化
This commit is contained in:
@@ -8,6 +8,7 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54
|
||||
github.com/qiniu/go-sdk/v7 v7.25.6
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.72
|
||||
@@ -36,7 +37,6 @@ require (
|
||||
github.com/google/go-querystring v1.0.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -125,3 +125,122 @@ func AdminGetOperationLogs(c *gin.Context) {
|
||||
|
||||
utils.Success(c, res)
|
||||
}
|
||||
|
||||
// AdminGetAccessLogs 获取访问日志列表
|
||||
func AdminGetAccessLogs(c *gin.Context) {
|
||||
// 获取分页参数
|
||||
page := 1
|
||||
pageSize := 10
|
||||
|
||||
// 从查询参数中获取分页信息
|
||||
if c.Query("page") != "" {
|
||||
if p, err := strconv.Atoi(c.Query("page")); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
if c.Query("pageSize") != "" {
|
||||
if ps, err := strconv.Atoi(c.Query("pageSize")); err == nil {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
// 获取访问日志
|
||||
logs, total, err := repositories.GetAccessLogs(page, pageSize, nil)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
var logList []gin.H
|
||||
for _, log := range logs {
|
||||
logList = append(logList, gin.H{
|
||||
"id": log.ID,
|
||||
"ip": log.IP,
|
||||
"userAgent": log.UserAgent,
|
||||
"path": log.Path,
|
||||
"method": log.Method,
|
||||
"statusCode": log.StatusCode,
|
||||
"responseTime": log.ResponseTime,
|
||||
"region": log.Region,
|
||||
"createdAt": time.Unix(log.CreatedAt, 0).Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
if logList == nil {
|
||||
logList = []gin.H{}
|
||||
}
|
||||
|
||||
res := gin.H{
|
||||
"list": logList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
}
|
||||
|
||||
utils.Success(c, res)
|
||||
}
|
||||
|
||||
// AdminGetPostAccessLogs 获取指定文章的访问记录
|
||||
func AdminGetPostAccessLogs(c *gin.Context) {
|
||||
// 获取文章ID
|
||||
postIDStr := c.Param("id")
|
||||
postID, err := strconv.Atoi(postIDStr)
|
||||
if err != nil {
|
||||
utils.Error(c, 400, "Invalid post ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分页参数
|
||||
page := 1
|
||||
pageSize := 20
|
||||
|
||||
if c.Query("page") != "" {
|
||||
if p, err := strconv.Atoi(c.Query("page")); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
if c.Query("pageSize") != "" {
|
||||
if ps, err := strconv.Atoi(c.Query("pageSize")); err == nil {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文章的访问记录
|
||||
logs, total, err := repositories.GetPostAccessLogs(postID, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
var logList []gin.H
|
||||
for _, log := range logs {
|
||||
logList = append(logList, gin.H{
|
||||
"id": log.ID,
|
||||
"ip": log.IP,
|
||||
"userAgent": log.UserAgent,
|
||||
"path": log.Path,
|
||||
"method": log.Method,
|
||||
"statusCode": log.StatusCode,
|
||||
"responseTime": log.ResponseTime,
|
||||
"region": log.Region,
|
||||
"createdAt": time.Unix(log.CreatedAt, 0).Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
if logList == nil {
|
||||
logList = []gin.H{}
|
||||
}
|
||||
|
||||
res := gin.H{
|
||||
"list": logList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
}
|
||||
|
||||
utils.Success(c, res)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@ func main() {
|
||||
// 操作日志管理
|
||||
authAdmin.GET("/operation-logs", middleware.PermissionMiddleware("operation_logs", "read"), handlers.AdminGetOperationLogs)
|
||||
|
||||
// 访问日志管理
|
||||
authAdmin.GET("/access-logs", middleware.PermissionMiddleware("operation_logs", "read"), handlers.AdminGetAccessLogs)
|
||||
authAdmin.GET("/posts/:id/access-logs", middleware.PermissionMiddleware("posts", "read"), handlers.AdminGetPostAccessLogs)
|
||||
|
||||
// 仪表盘数据
|
||||
authAdmin.GET("/dashboard/activities", middleware.PermissionMiddleware("dashboard", "read"), handlers.AdminGetRecentActivities)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
"github.com/niangaodev/art-code/repositories"
|
||||
"github.com/niangaodev/art-code/utils"
|
||||
)
|
||||
|
||||
// OperationLogMiddleware 操作日志中间件
|
||||
@@ -46,11 +47,16 @@ func OperationLogMiddleware() gin.HandlerFunc {
|
||||
|
||||
username, _ := c.Get("username")
|
||||
|
||||
// 获取IP归属地
|
||||
ip := c.ClientIP()
|
||||
region := utils.GetRegion(ip)
|
||||
|
||||
// 构建操作日志
|
||||
operationLog := &models.OperationLog{
|
||||
UserID: userID.(uint),
|
||||
Username: username.(string),
|
||||
IP: c.ClientIP(),
|
||||
IP: ip,
|
||||
Region: region,
|
||||
Path: c.Request.URL.Path,
|
||||
Method: c.Request.Method,
|
||||
Params: string(requestBody),
|
||||
|
||||
@@ -12,6 +12,7 @@ type OperationLog struct {
|
||||
UserID uint `json:"userId" gorm:"column:user_id;index"`
|
||||
Username string `json:"username" gorm:"column:username"`
|
||||
IP string `json:"ip" gorm:"column:ip"`
|
||||
Region string `json:"region" gorm:"column:region"` // IP归属地
|
||||
Path string `json:"path" gorm:"column:path;index"`
|
||||
Method string `json:"method" gorm:"column:method"`
|
||||
Params string `json:"params" gorm:"column:params;type:text"`
|
||||
@@ -43,6 +44,7 @@ type OperationLogResponse struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
Region string `json:"region"` // IP归属地
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Params string `json:"params"`
|
||||
|
||||
@@ -2,6 +2,8 @@ package repositories
|
||||
|
||||
import (
|
||||
// "log"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/niangaodev/art-code/config"
|
||||
@@ -116,22 +118,79 @@ func GetDailyUV(startDate, endDate string) ([]UVTrendData, error) {
|
||||
return fullResults, nil
|
||||
}
|
||||
|
||||
// GetUserRegions 获取用户地域分布
|
||||
// GetAccessLogs 获取访问日志列表(支持分页和筛选)
|
||||
func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64, error) {
|
||||
query := config.DB.Model(&models.AccessLog{}).
|
||||
Where("deleted_at = ?", 0)
|
||||
|
||||
// 如果指定了文章ID,筛选该文章的访问记录
|
||||
if postID != nil {
|
||||
// 路径格式可能是 /api/posts/{id} 或 /blog/{id} 等
|
||||
// 使用 LIKE 匹配包含文章ID的路径
|
||||
query = query.Where("path LIKE ?", fmt.Sprintf("%%/posts/%d%%", *postID))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var logs []models.AccessLog
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&logs).Error
|
||||
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
// GetPostAccessLogs 获取指定文章的访问记录
|
||||
func GetPostAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) {
|
||||
return GetAccessLogs(page, pageSize, &postID)
|
||||
}
|
||||
|
||||
// ExtractProvinceFromRegion 从归属地字符串中提取省份信息
|
||||
// 格式: 国家|区域|省份|城市|ISP
|
||||
// 例如: 中国|0|浙江|杭州市|联通 -> 浙江
|
||||
func ExtractProvinceFromRegion(region string) string {
|
||||
if region == "" || region == "Unknown" || region == "Internal" {
|
||||
return "未知"
|
||||
}
|
||||
|
||||
parts := strings.Split(region, "|")
|
||||
if len(parts) >= 3 {
|
||||
province := strings.TrimSpace(parts[2])
|
||||
if province != "" && province != "0" {
|
||||
// 移除"省"、"市"、"自治区"等后缀,统一格式
|
||||
province = strings.TrimSuffix(province, "省")
|
||||
province = strings.TrimSuffix(province, "市")
|
||||
province = strings.TrimSuffix(province, "自治区")
|
||||
province = strings.TrimSuffix(province, "特别行政区")
|
||||
return province
|
||||
}
|
||||
}
|
||||
|
||||
return "未知"
|
||||
}
|
||||
|
||||
// GetUserRegions 获取用户地域分布(使用 access_logs 表的 region 字段)
|
||||
func GetUserRegions(startDate, endDate string) ([]struct {
|
||||
Region string
|
||||
Count int
|
||||
}, error) {
|
||||
query := config.DB.Model(&models.UserAccessLog{}).
|
||||
Select("COALESCE(NULLIF(user_location, ''), 'Unknown') as region, COUNT(DISTINCT user_ip) as count")
|
||||
query := config.DB.Model(&models.AccessLog{}).
|
||||
Select("COALESCE(NULLIF(region, ''), 'Unknown') as region, COUNT(DISTINCT ip) as count").
|
||||
Where("deleted_at = ?", 0)
|
||||
|
||||
if startDate != "" {
|
||||
startUnix := parseDateToUnix(startDate, false)
|
||||
query = query.Where("access_time >= ?", startUnix)
|
||||
query = query.Where("created_at >= ?", startUnix)
|
||||
}
|
||||
|
||||
if endDate != "" {
|
||||
endUnix := parseDateToUnix(endDate, true)
|
||||
query = query.Where("access_time <= ?", endUnix)
|
||||
query = query.Where("created_at <= ?", endUnix)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
@@ -140,8 +199,43 @@ func GetUserRegions(startDate, endDate string) ([]struct {
|
||||
}
|
||||
err := query.Group("region").
|
||||
Order("count DESC").
|
||||
Limit(20).
|
||||
Limit(50).
|
||||
Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析归属地,提取省份信息并聚合
|
||||
provinceMap := make(map[string]int)
|
||||
for _, r := range results {
|
||||
province := ExtractProvinceFromRegion(r.Region)
|
||||
provinceMap[province] += r.Count
|
||||
}
|
||||
|
||||
// 转换为结果格式
|
||||
var finalResults []struct {
|
||||
Region string
|
||||
Count int
|
||||
}
|
||||
for province, count := range provinceMap {
|
||||
finalResults = append(finalResults, struct {
|
||||
Region string
|
||||
Count int
|
||||
}{
|
||||
Region: province,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
|
||||
// 按数量排序
|
||||
for i := 0; i < len(finalResults)-1; i++ {
|
||||
for j := i + 1; j < len(finalResults); j++ {
|
||||
if finalResults[i].Count < finalResults[j].Count {
|
||||
finalResults[i], finalResults[j] = finalResults[j], finalResults[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalResults, nil
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogRes
|
||||
UserID: log.UserID,
|
||||
Username: log.Username,
|
||||
IP: log.IP,
|
||||
Region: log.Region,
|
||||
Path: log.Path,
|
||||
Method: log.Method,
|
||||
Params: log.Params,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var (
|
||||
searcher *xdb.Searcher
|
||||
once sync.Once
|
||||
mu sync.RWMutex // 保护 searcher 的并发访问
|
||||
)
|
||||
|
||||
// InitIP2Region 初始化 ip2region
|
||||
@@ -52,7 +53,9 @@ func InitIP2Region(dbPath string) {
|
||||
// 如果最终路径为空,说明找不到文件
|
||||
if finalPath == "" {
|
||||
log.Printf("IP2Region database file not found. Region lookup will be disabled.")
|
||||
mu.Lock()
|
||||
searcher = nil
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,16 +63,26 @@ func InitIP2Region(dbPath string) {
|
||||
cBuff, err := xdb.LoadContentFromFile(finalPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load ip2region.xdb from %s: %v. Region lookup will be disabled.", finalPath, err)
|
||||
mu.Lock()
|
||||
searcher = nil
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
searcher, err = xdb.NewWithBuffer(nil, cBuff)
|
||||
newSearcher, err := xdb.NewWithBuffer(nil, cBuff)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create searcher: %v", err)
|
||||
mu.Lock()
|
||||
searcher = nil
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// 使用写锁设置 searcher
|
||||
mu.Lock()
|
||||
searcher = newSearcher
|
||||
mu.Unlock()
|
||||
|
||||
log.Printf("IP2Region loaded successfully from %s", finalPath)
|
||||
})
|
||||
}
|
||||
@@ -84,7 +97,13 @@ func GetRegion(ip string) string {
|
||||
}
|
||||
}()
|
||||
|
||||
if searcher == nil {
|
||||
// 使用读锁保护并发访问
|
||||
mu.RLock()
|
||||
s := searcher
|
||||
mu.RUnlock()
|
||||
|
||||
// 再次检查 searcher 是否为 nil
|
||||
if s == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
@@ -93,8 +112,25 @@ func GetRegion(ip string) string {
|
||||
return "Internal"
|
||||
}
|
||||
|
||||
region, err := searcher.SearchByStr(ip)
|
||||
// 再次检查 searcher 是否为 nil(防止在检查后、调用前被设置为 nil)
|
||||
mu.RLock()
|
||||
s = searcher
|
||||
mu.RUnlock()
|
||||
|
||||
if s == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// 使用 defer recover 保护 SearchByStr 调用
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Panic in searcher.SearchByStr for IP %s: %v", ip, r)
|
||||
}
|
||||
}()
|
||||
|
||||
region, err := s.SearchByStr(ip)
|
||||
if err != nil {
|
||||
log.Printf("Error searching region for IP %s: %v", ip, err)
|
||||
return "Unknown"
|
||||
}
|
||||
return region
|
||||
|
||||
Reference in New Issue
Block a user