Files
nl-blogs/server/repositories/log_repository.go
2026-06-24 17:06:22 +08:00

326 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repositories
import (
// "log"
"fmt"
"net/url"
"strings"
"time"
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
)
// Helper to parse date string to unix timestamp
func parseDateToUnix(dateStr string, isEnd bool) int64 {
if dateStr == "" {
return 0
}
// 处理URL编码格式先尝试URL解码然后将+替换为空格
decoded := dateStr
if decodedStr, err := url.QueryUnescape(dateStr); err == nil {
decoded = decodedStr
}
// URL编码中+可能代表空格,需要替换
decoded = strings.ReplaceAll(decoded, "+", " ")
// Try parsing with time first
t, err := time.ParseInLocation("2006-01-02 15:04", decoded, time.Local)
if err == nil {
if isEnd {
// HH:mm:59
return t.Add(59 * time.Second).Unix()
}
return t.Unix()
}
// Try parsing just date
t, err = time.ParseInLocation("2006-01-02", decoded, time.Local)
if err == nil {
if isEnd {
// 23:59:59
return t.Add(24*time.Hour - 1*time.Second).Unix()
}
return t.Unix()
}
return 0
}
// CreateAccessLog 创建访问日志
func CreateAccessLog(log *models.AccessLog) error {
return config.DB.Create(log).Error
}
// UVTrendData UV趋势数据
type UVTrendData struct {
Date string `json:"date"`
Count int `json:"value"`
YoY float64 `json:"yoy"`
MoM float64 `json:"mom"`
}
// GetDailyUV 获取UV趋势
func GetDailyUV(startDate, endDate string) ([]UVTrendData, error) {
// 确定日期范围
var startTime, endTime time.Time
if startDate != "" {
startUnix := parseDateToUnix(startDate, false)
startTime = time.Unix(startUnix, 0)
} else {
startTime = time.Now().AddDate(0, 0, -6)
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, time.Local)
}
if endDate != "" {
endUnix := parseDateToUnix(endDate, true)
endTime = time.Unix(endUnix, 0)
} else {
endTime = time.Now()
endTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), 23, 59, 59, 0, time.Local)
}
// 查询数据库
query := config.DB.Model(&models.UserAccessLog{}).
Select("FROM_UNIXTIME(access_time, '%Y-%m-%d') as date, COUNT(DISTINCT user_ip) as count").
Where("access_time >= ?", startTime.Unix()).
Where("access_time <= ?", endTime.Unix())
var results []UVTrendData
err := query.Group("date").
Order("date ASC").
Scan(&results).Error
if err != nil {
return nil, err
}
// 创建日期到数据的映射
resultMap := make(map[string]UVTrendData)
for _, r := range results {
resultMap[r.Date] = r
}
// 生成完整日期列表
var fullResults []UVTrendData
current := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, time.Local)
endDay := time.Date(endTime.Year(), endTime.Month(), endTime.Day(), 0, 0, 0, 0, time.Local)
for !current.After(endDay) {
dateStr := current.Format("2006-01-02")
if data, exists := resultMap[dateStr]; exists {
data.YoY = 0
data.MoM = 0
fullResults = append(fullResults, data)
} else {
// 填充0值
fullResults = append(fullResults, UVTrendData{
Date: dateStr,
Count: 0,
YoY: 0,
MoM: 0,
})
}
current = current.AddDate(0, 0, 1)
}
return fullResults, nil
}
// GetAccessLogs 获取访问日志列表(支持分页和筛选)
func GetAccessLogs(page, pageSize int, postID *int, filter AccessLogFilter) ([]models.AccessLog, int64, error) {
query := config.DB.Model(&models.AccessLog{}).
Where("deleted_at = ?", 0)
// 如果指定了文章ID筛选该文章的访问记录
if postID != nil {
// 路径格式可能是:
// - /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),
)
}
if filter.Path != "" {
query = query.Where("path LIKE ?", "%"+filter.Path+"%")
}
if filter.Region != "" {
query = query.Where("region LIKE ?", "%"+filter.Region+"%")
}
if filter.StartDate != "" {
startUnix := parseDateToUnix(filter.StartDate, false)
if startUnix > 0 {
query = query.Where("created_at >= ?", startUnix)
}
}
if filter.EndDate != "" {
endUnix := parseDateToUnix(filter.EndDate, true)
if endUnix > 0 {
query = query.Where("created_at <= ?", endUnix)
}
}
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
}
// 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, AccessLogFilter{})
}
// 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 "未知"
}
// 所有省份列表34个
var allProvinces = []string{
"北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江",
"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南",
"湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州",
"云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", "台湾", "香港", "澳门",
}
// GetUserRegionsFromAccessLogs 获取用户地域分布(使用 access_logs 表的 region 字段)
// 返回所有34个省份的数据没有数据的省份 Count 设为 0
// 注意此函数查询的是所有访问记录包括静态资源、API调用等
// 建议使用 GetUserRegionsFromUserAccessLogs 获取更准确的用户访问统计
func GetUserRegionsFromAccessLogs(startDate, endDate string) ([]struct {
Region string
Count int
}, error) {
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("created_at >= ?", startUnix)
}
if endDate != "" {
endUnix := parseDateToUnix(endDate, true)
query = query.Where("created_at <= ?", 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
}
}
// 为所有省份生成数据没有数据的设为0
var finalResults []struct {
Region string
Count int
}
for _, province := range allProvinces {
count := provinceMap[province]
finalResults = append(finalResults, struct {
Region string
Count int
}{
Region: province,
Count: count,
})
}
return finalResults, nil
}
// GetDefaultUserRegions 返回所有34个省份的默认数据Count为0
func GetDefaultUserRegions() []struct {
Region string
Count int
} {
var defaultResults []struct {
Region string
Count int
}
for _, province := range allProvinces {
defaultResults = append(defaultResults, struct {
Region string
Count int
}{
Region: province,
Count: 0,
})
}
return defaultResults
}