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 }