数据结构优化

This commit is contained in:
李琦
2026-01-20 15:23:37 +08:00
parent 946855bff2
commit cdbd3dc038
14 changed files with 835 additions and 198 deletions

View File

@@ -40,8 +40,8 @@ func GetDashboardStats(c *gin.Context) {
uvTrend = []repositories.UVTrendData{}
}
// 3. 用户画像-地域分布
userRegions, err := repositories.GetUserRegions(startDate, endDate)
// 3. 用户画像-地域分布(使用 user_access_logs 表)
userRegions, err := repositories.GetUserRegionsFromUserAccessLogs(startDate, endDate)
if err != nil {
// 即使出错也返回所有34个省份的默认数据Count为0
userRegions = repositories.GetDefaultUserRegions()

View File

@@ -208,8 +208,8 @@ func AdminGetPostAccessLogs(c *gin.Context) {
}
}
// 获取文章的访问记录
logs, total, err := repositories.GetPostAccessLogs(postID, page, pageSize)
// 获取文章的访问记录(从 user_access_logs 表)
logs, total, err := repositories.GetPostAccessLogsFromUserAccessLogs(postID, page, pageSize)
if err != nil {
utils.ServerError(c, err)
return
@@ -219,15 +219,12 @@ func AdminGetPostAccessLogs(c *gin.Context) {
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"),
"id": log.ID,
"userId": log.UserID,
"ip": log.UserIP,
"region": log.UserLocation,
"articleId": log.ArticleID,
"createdAt": time.Unix(log.AccessTime, 0).Format("2006-01-02 15:04:05"),
})
}

View File

@@ -47,6 +47,7 @@ type OperationLogResponse struct {
Region string `json:"region"` // IP归属地
Path string `json:"path"`
Method string `json:"method"`
Action string `json:"action"` // 操作描述
Params string `json:"params"`
Status int `json:"status"`
Duration int `json:"duration"`

View File

@@ -150,9 +150,9 @@ func DeleteColumn(id uint) error {
func GetPostsByColumnID(columnID uint) ([]models.Post, error) {
var posts []models.Post
err := config.DB.Model(&models.Post{}).
Select("posts.*, categories.name as category_name").
Preload("Category").
Preload("Tags").
Joins("JOIN column_posts cp ON posts.id = cp.post_id").
Joins("LEFT JOIN categories ON posts.category_id = categories.id").
Where("cp.column_id = ? AND posts.deleted_at = ? AND posts.is_published = ?", columnID, 0, 1).
Order("cp.sort_order ASC, posts.created_at DESC").
Find(&posts).Error

View File

@@ -135,9 +135,37 @@ func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64,
// 如果指定了文章ID筛选该文章的访问记录
if postID != nil {
// 路径格式可能是 /api/posts/{id} 或 /blog/{id} 等
// 使用 LIKE 匹配包含文章ID的路径
query = query.Where("path LIKE ?", fmt.Sprintf("%%/posts/%d%%", *postID))
// 路径格式可能是
// - /api/posts/{id} (API调用)
// - /blog/{id} (前端页面访问)
// - /posts/{id} (其他可能的格式)
// 使用精确的匹配避免匹配到其他ID如123匹配到1234
// 使用LIKE模式确保ID后面是 / 或 ? 或 字符串结束
postIDStr := fmt.Sprintf("%d", *postID)
// 匹配精确的路径格式,避免部分匹配
// 例如:/api/posts/123 或 /api/posts/123/ 或 /api/posts/123?xxx
// 但不匹配 /api/posts/1234
// 使用精确的 LIKE 匹配确保只匹配正确的文章ID
// 匹配格式:/api/posts/{id}、/api/posts/{id}/、/api/posts/{id}?xxx
// 匹配格式:/blog/{id}、/blog/{id}/、/blog/{id}?xxx
// 匹配格式:/posts/{id}、/posts/{id}/、/posts/{id}?xxx
query = query.Where(
"(path = ? OR path LIKE ? OR path LIKE ? OR "+
"path = ? OR path LIKE ? OR path LIKE ? OR "+
"path = ? OR path LIKE ? OR path LIKE ?)",
// /api/posts/{id} 格式3个参数
fmt.Sprintf("/api/posts/%s", postIDStr),
fmt.Sprintf("/api/posts/%s/%%", postIDStr),
fmt.Sprintf("/api/posts/%s?%%", postIDStr),
// /blog/{id} 格式3个参数
fmt.Sprintf("/blog/%s", postIDStr),
fmt.Sprintf("/blog/%s/%%", postIDStr),
fmt.Sprintf("/blog/%s?%%", postIDStr),
// /posts/{id} 格式3个参数
fmt.Sprintf("/posts/%s", postIDStr),
fmt.Sprintf("/posts/%s/%%", postIDStr),
fmt.Sprintf("/posts/%s?%%", postIDStr),
)
}
var total int64
@@ -155,8 +183,9 @@ func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64,
return logs, total, err
}
// GetPostAccessLogs 获取指定文章的访问记录
func GetPostAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) {
// GetPostAccessLogsFromAccessLogs 从 access_logs 表获取指定文章的访问记录(已废弃,应使用 user_access_logs
// 保留此函数以保持向后兼容,但建议使用 user_access_log_repository.GetPostAccessLogs
func GetPostAccessLogsFromAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) {
return GetAccessLogs(page, pageSize, &postID)
}
@@ -192,9 +221,11 @@ var allProvinces = []string{
"云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", "台湾", "香港", "澳门",
}
// GetUserRegions 获取用户地域分布(使用 access_logs 表的 region 字段)
// GetUserRegionsFromAccessLogs 获取用户地域分布(使用 access_logs 表的 region 字段)
// 返回所有34个省份的数据没有数据的省份 Count 设为 0
func GetUserRegions(startDate, endDate string) ([]struct {
// 注意此函数查询的是所有访问记录包括静态资源、API调用等
// 建议使用 GetUserRegionsFromUserAccessLogs 获取更准确的用户访问统计
func GetUserRegionsFromAccessLogs(startDate, endDate string) ([]struct {
Region string
Count int
}, error) {

View File

@@ -6,6 +6,7 @@ import (
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
"github.com/niangaodev/art-code/utils"
)
// CreateOperationLog 创建操作日志
@@ -51,6 +52,9 @@ func GetOperationLogs(page, pageSize int) ([]models.OperationLog, int64, error)
// BuildOperationLogResponse 构建操作日志响应
func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogResponse {
// 生成操作描述
action := utils.GetOperationAction(log.Path, log.Method)
return &models.OperationLogResponse{
ID: log.ID,
UserID: log.UserID,
@@ -59,6 +63,7 @@ func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogRes
Region: log.Region,
Path: log.Path,
Method: log.Method,
Action: action,
Params: log.Params,
Status: log.Status,
Duration: log.Duration,

View File

@@ -2,6 +2,8 @@ package repositories
import (
"log"
"regexp"
"strings"
"time"
"github.com/niangaodev/art-code/config"
@@ -70,3 +72,140 @@ func GetTopArticlesByAccess(limit int) ([]struct {
return results, err
}
// GetPostAccessLogsFromUserAccessLogs 获取指定文章的访问记录(从 user_access_logs 表)
func GetPostAccessLogsFromUserAccessLogs(postID int, page, pageSize int) ([]models.UserAccessLog, int64, error) {
query := config.DB.Model(&models.UserAccessLog{}).
Where("deleted_at = ?", 0).
Where("article_id = ?", postID)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var logs []models.UserAccessLog
offset := (page - 1) * pageSize
err := query.Order("access_time DESC").
Limit(pageSize).
Offset(offset).
Find(&logs).Error
return logs, total, err
}
// GetUserRegionsFromUserAccessLogs 获取用户地域分布(使用 user_access_logs 表的 user_location 字段)
// 返回所有34个省份的数据没有数据的省份 Count 设为 0
func GetUserRegionsFromUserAccessLogs(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").
Where("deleted_at = ?", 0)
if startDate != "" {
startUnix := parseDateToUnix(startDate, false)
query = query.Where("access_time >= ?", startUnix)
}
if endDate != "" {
endUnix := parseDateToUnix(endDate, true)
query = query.Where("access_time <= ?", endUnix)
}
var results []struct {
Region string
Count int
}
err := query.Group("region").
Order("count DESC").
Limit(50).
Scan(&results).Error
if err != nil {
return nil, err
}
// 解析归属地,提取省份信息并聚合
// 注意:现在返回完整的省份名称(如"浙江省"、"北京市"等),不进行任何处理
provinceMap := make(map[string]int)
for _, r := range results {
province := extractProvinceFromRegion(r.Region)
if province != "未知" {
provinceMap[province] += r.Count
log.Printf("Extracted province: %s from region: %s, count: %d", province, r.Region, r.Count)
} else {
log.Printf("Failed to extract province from region: %s", r.Region)
}
}
// 定义完整的省份名称列表(包含"省"、"市"、"自治区"等后缀)
allProvincesFullNames := []string{
"北京市", "天津市", "河北省", "山西省", "内蒙古自治区", "辽宁省", "吉林省", "黑龙江省",
"上海市", "江苏省", "浙江省", "安徽省", "福建省", "江西省", "山东省", "河南省",
"湖北省", "湖南省", "广东省", "广西壮族自治区", "海南省", "重庆市", "四川省", "贵州省",
"云南省", "西藏自治区", "陕西省", "甘肃省", "青海省", "宁夏回族自治区", "新疆维吾尔自治区", "台湾省", "香港特别行政区", "澳门特别行政区",
}
// 为所有省份生成数据没有数据的设为0
var finalResults []struct {
Region string
Count int
}
for _, province := range allProvincesFullNames {
count := provinceMap[province]
finalResults = append(finalResults, struct {
Region string
Count int
}{
Region: province,
Count: count,
})
}
return finalResults, nil
}
// 所有省份列表34个- 用于用户访问日志统计
var allProvincesForUserAccess = []string{
"北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江",
"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南",
"湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州",
"云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", "台湾", "香港", "澳门",
}
// extractProvinceFromRegion 从归属地字符串中提取省份信息
// 格式: 国家|省份|城市|ISP
// 例如: 中国|浙江省|杭州市|电信 -> 浙江省
// 注意:返回完整的省份名称,包括"省"、"市"、"自治区"等后缀,以匹配地图数据
func extractProvinceFromRegion(region string) string {
if region == "" || region == "Unknown" || region == "Internal" {
return "未知"
}
// 使用正则表达式验证格式: 国家|省份|城市|ISP
// 匹配格式: 至少包含"国家|省份"两部分
regionPattern := regexp.MustCompile(`^[^|]+\|[^|]+`)
if !regionPattern.MatchString(region) {
log.Printf("Invalid region format (regex validation failed): %s", region)
return "未知"
}
parts := strings.Split(region, "|")
// 格式应该是: 国家|省份|城市|ISP (至少需要2个部分)
if len(parts) < 2 {
log.Printf("Invalid region format (insufficient parts): %s, parts: %v", region, parts)
return "未知"
}
province := strings.TrimSpace(parts[1]) // 省份在索引1
if province == "" || province == "0" {
log.Printf("Invalid province in region: %s, province part: %s", region, province)
return "未知"
}
// 直接返回完整的省份名称,不移除"省"、"市"、"自治区"等后缀
// 这样可以直接匹配地图数据中的省份名称
return province
}

View File

@@ -0,0 +1,345 @@
package utils
import (
"strings"
)
// GetOperationAction 根据路径和方法生成友好的中文操作描述
func GetOperationAction(path, method string) string {
// 统一转换为小写以便匹配
pathLower := strings.ToLower(path)
methodUpper := strings.ToUpper(method)
// 用户管理
if strings.Contains(pathLower, "/api/admin/users") {
if methodUpper == "GET" {
if strings.Contains(pathLower, "/users/") && !strings.HasSuffix(pathLower, "/users") {
return "查看用户详情"
}
return "查看用户列表"
}
if methodUpper == "POST" {
return "创建用户"
}
if methodUpper == "PUT" {
return "更新用户"
}
if methodUpper == "DELETE" {
return "删除用户"
}
}
// 角色管理
if strings.Contains(pathLower, "/api/admin/roles") {
if methodUpper == "GET" {
if strings.Contains(pathLower, "/permissions") {
return "查看权限列表"
}
return "查看角色列表"
}
if methodUpper == "POST" {
return "创建角色"
}
if methodUpper == "PUT" {
if strings.Contains(pathLower, "/permissions") {
return "更新角色权限"
}
return "更新角色"
}
if methodUpper == "DELETE" {
return "删除角色"
}
}
// 文章管理
if strings.Contains(pathLower, "/api/admin/posts") {
if methodUpper == "GET" {
if strings.Contains(pathLower, "/history") {
return "查看文章历史"
}
if strings.Contains(pathLower, "/access-logs") {
return "查看文章访问日志"
}
return "查看文章列表"
}
if methodUpper == "POST" {
return "创建文章"
}
if methodUpper == "PUT" {
return "更新文章"
}
if methodUpper == "PATCH" {
if strings.Contains(pathLower, "/status") {
return "切换文章状态"
}
return "更新文章"
}
if methodUpper == "DELETE" {
return "删除文章"
}
}
// 分类管理
if strings.Contains(pathLower, "/api/admin/categories") {
if methodUpper == "GET" {
return "查看分类列表"
}
if methodUpper == "POST" {
return "创建分类"
}
if methodUpper == "PUT" {
return "更新分类"
}
if methodUpper == "DELETE" {
return "删除分类"
}
}
// 专栏管理
if strings.Contains(pathLower, "/api/admin/columns") {
if methodUpper == "GET" {
return "查看专栏列表"
}
if methodUpper == "POST" {
if strings.Contains(pathLower, "/posts") {
return "添加文章到专栏"
}
return "创建专栏"
}
if methodUpper == "PUT" {
return "更新专栏"
}
if methodUpper == "DELETE" {
if strings.Contains(pathLower, "/posts/") {
return "从专栏移除文章"
}
return "删除专栏"
}
}
// 标签管理
if strings.Contains(pathLower, "/api/admin/tags") {
if methodUpper == "GET" {
return "查看标签列表"
}
if methodUpper == "POST" {
return "创建标签"
}
if methodUpper == "PUT" {
return "更新标签"
}
if methodUpper == "DELETE" {
return "删除标签"
}
}
// 代码片段管理
if strings.Contains(pathLower, "/api/admin/snippets") {
if methodUpper == "GET" {
return "查看代码片段列表"
}
if methodUpper == "POST" {
return "创建代码片段"
}
if methodUpper == "PUT" {
return "更新代码片段"
}
if methodUpper == "DELETE" {
return "删除代码片段"
}
}
// 作品管理
if strings.Contains(pathLower, "/api/admin/works") {
if methodUpper == "GET" {
return "查看作品列表"
}
if methodUpper == "POST" {
return "创建作品"
}
if methodUpper == "PUT" {
return "更新作品"
}
if methodUpper == "DELETE" {
return "删除作品"
}
}
// 系统设置管理
if strings.Contains(pathLower, "/api/admin/settings") {
if methodUpper == "GET" {
return "查看系统设置"
}
if methodUpper == "POST" {
return "创建系统设置"
}
if methodUpper == "PUT" {
return "更新系统设置"
}
if methodUpper == "DELETE" {
return "删除系统设置"
}
}
// 关于页面管理
if strings.Contains(pathLower, "/api/admin/about") {
if methodUpper == "GET" {
return "查看关于页面"
}
if methodUpper == "POST" {
return "创建关于页面"
}
if methodUpper == "PUT" {
return "更新关于页面"
}
if methodUpper == "DELETE" {
return "删除关于页面"
}
}
// 客户评价管理
if strings.Contains(pathLower, "/api/admin/testimonials") {
if methodUpper == "POST" {
return "创建客户评价"
}
if methodUpper == "PUT" {
return "更新客户评价"
}
if methodUpper == "DELETE" {
return "删除客户评价"
}
}
// 合作伙伴管理
if strings.Contains(pathLower, "/api/admin/partners") {
if methodUpper == "POST" {
return "创建合作伙伴"
}
if methodUpper == "PUT" {
return "更新合作伙伴"
}
if methodUpper == "DELETE" {
return "删除合作伙伴"
}
}
// 咨询管理
if strings.Contains(pathLower, "/api/admin/inquiries") {
if methodUpper == "GET" {
return "查看咨询列表"
}
if methodUpper == "PUT" {
if strings.Contains(pathLower, "/status") {
return "更新咨询状态"
}
return "更新咨询"
}
}
// 邮箱后缀配置
if strings.Contains(pathLower, "/api/admin/email-suffixes") {
if methodUpper == "GET" {
return "查看邮箱后缀列表"
}
if methodUpper == "POST" {
return "创建邮箱后缀"
}
if methodUpper == "PUT" {
return "更新邮箱后缀"
}
if methodUpper == "DELETE" {
return "删除邮箱后缀"
}
}
// 搜索记录管理
if strings.Contains(pathLower, "/api/admin/search-logs") {
if methodUpper == "GET" {
return "查看搜索记录列表"
}
if methodUpper == "DELETE" {
return "删除搜索记录"
}
}
// 附件管理
if strings.Contains(pathLower, "/api/admin/attachments") {
if methodUpper == "GET" {
return "查看附件列表"
}
if methodUpper == "POST" {
if strings.Contains(pathLower, "/upload") {
return "上传附件"
}
return "创建附件"
}
if methodUpper == "PUT" {
return "更新附件"
}
if methodUpper == "DELETE" {
return "删除附件"
}
}
// 附件分类管理
if strings.Contains(pathLower, "/api/admin/attachment-categories") {
if methodUpper == "GET" {
return "查看附件分类列表"
}
if methodUpper == "POST" {
return "创建附件分类"
}
if methodUpper == "PUT" {
return "更新附件分类"
}
if methodUpper == "DELETE" {
return "删除附件分类"
}
}
// OSS配置管理
if strings.Contains(pathLower, "/api/admin/oss-configs") {
if methodUpper == "GET" {
return "查看OSS配置列表"
}
if methodUpper == "POST" {
return "创建OSS配置"
}
if methodUpper == "PUT" {
return "更新OSS配置"
}
if methodUpper == "DELETE" {
return "删除OSS配置"
}
}
// 操作日志管理
if strings.Contains(pathLower, "/api/admin/operation-logs") {
if methodUpper == "GET" {
return "查看操作日志"
}
}
// 访问日志管理
if strings.Contains(pathLower, "/api/admin/access-logs") {
if methodUpper == "GET" {
return "查看访问日志"
}
}
// 仪表盘
if strings.Contains(pathLower, "/api/admin/dashboard") {
if methodUpper == "GET" {
if strings.Contains(pathLower, "/stats") {
return "查看仪表盘统计"
}
if strings.Contains(pathLower, "/activities") {
return "查看最近活动"
}
return "查看仪表盘"
}
}
// 如果无法匹配,返回原始路径和方法作为后备
return methodUpper + " " + path
}