数据结构优化

This commit is contained in:
李琦
2026-01-20 11:00:42 +08:00
parent 29a642c285
commit b088ba48be
17 changed files with 1073 additions and 49 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`

View File

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

View File

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

View File

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