mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AnalyticsController struct {
|
||||
DB *gorm.DB
|
||||
umamiService *services.UmamiService
|
||||
}
|
||||
|
||||
func NewAnalyticsController(db *gorm.DB) *AnalyticsController {
|
||||
return &AnalyticsController{
|
||||
DB: db,
|
||||
umamiService: services.NewUmamiService(),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWebsiteID attempts to obtain a usable Umami website ID for requests
|
||||
func (ac *AnalyticsController) resolveWebsiteID() (string, error) {
|
||||
if id := strings.TrimSpace(config.AppConfig.UmamiWebsiteID); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Try to get the first available website
|
||||
id, err := ac.umamiService.GetDefaultWebsiteID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
config.AppConfig.UmamiWebsiteID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// getClientIP extracts the real client IP address
|
||||
func getClientIP(c *gin.Context) string {
|
||||
// Check X-Forwarded-For header
|
||||
xff := c.GetHeader("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
xri := c.GetHeader("X-Real-IP")
|
||||
if xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
// hashIP creates a privacy-preserving hash of IP address
|
||||
func hashIP(ip string) string {
|
||||
// Add salt to make it harder to reverse
|
||||
salted := ip + "_fotbal_club_2025"
|
||||
hash := sha256.Sum256([]byte(salted))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Track generic event (page view, click, etc.)
|
||||
func (ac *AnalyticsController) TrackEvent(c *gin.Context) {
|
||||
var payload models.VisitorEvent
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set client IP from request (hashed for privacy)
|
||||
realIP := getClientIP(c)
|
||||
payload.IPAddress = hashIP(realIP)
|
||||
payload.UserAgent = c.GetHeader("User-Agent")
|
||||
payload.Referrer = c.GetHeader("Referer")
|
||||
|
||||
// Synchronize Page and PagePath fields
|
||||
if payload.PagePath != "" && payload.Page == "" {
|
||||
payload.Page = payload.PagePath
|
||||
} else if payload.Page != "" && payload.PagePath == "" {
|
||||
payload.PagePath = payload.Page
|
||||
}
|
||||
|
||||
if err := ac.DB.Create(&payload).Error; err != nil {
|
||||
logger.Error("Failed to track event: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to track event"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GetAnalytics returns general analytics summary with users, events, and articles stats
|
||||
func (ac *AnalyticsController) GetAnalytics(c *gin.Context) {
|
||||
// Get users stats
|
||||
var totalUsers int64
|
||||
ac.DB.Model(&models.User{}).Count(&totalUsers)
|
||||
|
||||
// Get new users this week
|
||||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||
var newUsersThisWeek int64
|
||||
ac.DB.Model(&models.User{}).Where("created_at >= ?", weekAgo).Count(&newUsersThisWeek)
|
||||
|
||||
// Get events stats
|
||||
var totalEvents int64
|
||||
ac.DB.Model(&models.Event{}).Count(&totalEvents)
|
||||
|
||||
// Get upcoming events (events with start_time in the future)
|
||||
now := time.Now()
|
||||
var upcomingEvents int64
|
||||
ac.DB.Model(&models.Event{}).Where("start_time > ?", now).Count(&upcomingEvents)
|
||||
|
||||
// Get articles stats
|
||||
var totalArticles int64
|
||||
ac.DB.Model(&models.Article{}).Count(&totalArticles)
|
||||
|
||||
var publishedArticles int64
|
||||
ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": gin.H{
|
||||
"total": totalUsers,
|
||||
"new_this_week": newUsersThisWeek,
|
||||
},
|
||||
"events": gin.H{
|
||||
"total": totalEvents,
|
||||
"upcoming": upcomingEvents,
|
||||
},
|
||||
"articles": gin.H{
|
||||
"total": totalArticles,
|
||||
"published": publishedArticles,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetVisitors returns visitor statistics grouped by day
|
||||
func (ac *AnalyticsController) GetVisitors(c *gin.Context) {
|
||||
// Get parameters
|
||||
days := 30
|
||||
if d := c.Query("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
groupBy := c.DefaultQuery("groupBy", "day")
|
||||
startDate := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
type VisitorStat struct {
|
||||
Date string `json:"date"`
|
||||
PageViews int64 `json:"pageViews"`
|
||||
UniqueVisitors int64 `json:"uniqueVisitors"`
|
||||
}
|
||||
|
||||
var stats []VisitorStat
|
||||
|
||||
// Group by date
|
||||
if groupBy == "day" {
|
||||
ac.DB.Model(&models.VisitorEvent{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as page_views, COUNT(DISTINCT ip_address) as unique_visitors").
|
||||
Where("event_type = ? AND created_at >= ?", "page_view", startDate).
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC").
|
||||
Scan(&stats)
|
||||
} else {
|
||||
// Default to day grouping
|
||||
ac.DB.Model(&models.VisitorEvent{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as page_views, COUNT(DISTINCT ip_address) as unique_visitors").
|
||||
Where("event_type = ? AND created_at >= ?", "page_view", startDate).
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC").
|
||||
Scan(&stats)
|
||||
}
|
||||
|
||||
// Transform data for chart format
|
||||
labels := make([]string, 0, len(stats))
|
||||
pageViewsData := make([]int64, 0, len(stats))
|
||||
uniqueVisitorsData := make([]int64, 0, len(stats))
|
||||
|
||||
var totalVisitors int64
|
||||
for _, stat := range stats {
|
||||
// Format date as "d. M." (e.g., "5. 10.")
|
||||
t, err := time.Parse("2006-01-02", stat.Date)
|
||||
if err == nil {
|
||||
labels = append(labels, fmt.Sprintf("%d. %d.", t.Day(), int(t.Month())))
|
||||
} else {
|
||||
labels = append(labels, stat.Date)
|
||||
}
|
||||
pageViewsData = append(pageViewsData, stat.PageViews)
|
||||
uniqueVisitorsData = append(uniqueVisitorsData, stat.UniqueVisitors)
|
||||
totalVisitors += stat.UniqueVisitors
|
||||
}
|
||||
|
||||
// Calculate change percentage (compare last 7 days with previous 7 days)
|
||||
var changePercentage float64
|
||||
if len(stats) >= 14 {
|
||||
var recentSum, previousSum int64
|
||||
for i := len(stats) - 7; i < len(stats); i++ {
|
||||
recentSum += stats[i].UniqueVisitors
|
||||
}
|
||||
for i := len(stats) - 14; i < len(stats)-7; i++ {
|
||||
previousSum += stats[i].UniqueVisitors
|
||||
}
|
||||
if previousSum > 0 {
|
||||
changePercentage = float64(recentSum-previousSum) / float64(previousSum) * 100
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"totalVisitors": totalVisitors,
|
||||
"changePercentage": changePercentage,
|
||||
"chartData": gin.H{
|
||||
"labels": labels,
|
||||
"datasets": []gin.H{
|
||||
{
|
||||
"label": "Návštěvníci",
|
||||
"data": uniqueVisitorsData,
|
||||
"borderColor": "rgba(66, 153, 225, 1)",
|
||||
"backgroundColor": "rgba(66, 153, 225, 0.5)",
|
||||
"tension": 0.3,
|
||||
"fill": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAnalyticsOverview returns overview statistics for admin dashboard
|
||||
func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
|
||||
var totalPageViews, uniqueVisitors, pageViewsToday, pageViewsWeek, uniqueVisitorsWeek int64
|
||||
|
||||
// Try to fetch from Umami first
|
||||
websiteID, err := ac.resolveWebsiteID()
|
||||
if err == nil && websiteID != "" {
|
||||
// Fetch overall stats (last 365 days for total)
|
||||
endAt := time.Now().Unix() * 1000
|
||||
startAt := time.Now().AddDate(-1, 0, 0).Unix() * 1000
|
||||
|
||||
stats, err := ac.umamiService.GetWebsiteStats(websiteID, startAt, endAt)
|
||||
if err == nil {
|
||||
if pv, ok := stats["pageviews"].(map[string]interface{}); ok {
|
||||
if val, ok := pv["value"].(float64); ok {
|
||||
totalPageViews = int64(val)
|
||||
}
|
||||
}
|
||||
if v, ok := stats["visitors"].(map[string]interface{}); ok {
|
||||
if val, ok := v["value"].(float64); ok {
|
||||
uniqueVisitors = int64(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch today's stats
|
||||
todayStart := time.Now().Truncate(24 * time.Hour).Unix() * 1000
|
||||
todayEnd := time.Now().Unix() * 1000
|
||||
todayStats, err := ac.umamiService.GetWebsiteStats(websiteID, todayStart, todayEnd)
|
||||
if err == nil {
|
||||
if pv, ok := todayStats["pageviews"].(map[string]interface{}); ok {
|
||||
if val, ok := pv["value"].(float64); ok {
|
||||
pageViewsToday = int64(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch this week's stats
|
||||
weekStart := time.Now().AddDate(0, 0, -7).Unix() * 1000
|
||||
weekEnd := time.Now().Unix() * 1000
|
||||
weekStats, err := ac.umamiService.GetWebsiteStats(websiteID, weekStart, weekEnd)
|
||||
if err == nil {
|
||||
if pv, ok := weekStats["pageviews"].(map[string]interface{}); ok {
|
||||
if val, ok := pv["value"].(float64); ok {
|
||||
pageViewsWeek = int64(val)
|
||||
}
|
||||
}
|
||||
if v, ok := weekStats["visitors"].(map[string]interface{}); ok {
|
||||
if val, ok := v["value"].(float64); ok {
|
||||
uniqueVisitorsWeek = int64(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to internal analytics if Umami is not available
|
||||
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Count(&totalPageViews)
|
||||
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Distinct("ip_address").Count(&uniqueVisitors)
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND DATE(created_at) = ?", "page_view", today).Count(&pageViewsToday)
|
||||
|
||||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Count(&pageViewsWeek)
|
||||
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Distinct("ip_address").Count(&uniqueVisitorsWeek)
|
||||
}
|
||||
|
||||
// Total and published articles (always from DB)
|
||||
var totalArticles, publishedArticles int64
|
||||
ac.DB.Model(&models.Article{}).Count(&totalArticles)
|
||||
ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_page_views": totalPageViews,
|
||||
"unique_visitors": uniqueVisitors,
|
||||
"total_articles": totalArticles,
|
||||
"published_articles": publishedArticles,
|
||||
"page_views_today": pageViewsToday,
|
||||
"page_views_week": pageViewsWeek,
|
||||
"unique_visitors_week": uniqueVisitorsWeek,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTopPages returns the most visited pages
|
||||
func (ac *AnalyticsController) GetTopPages(c *gin.Context) {
|
||||
limit := 10
|
||||
if l := c.Query("limit"); l != "" {
|
||||
fmt.Sscanf(l, "%d", &limit)
|
||||
}
|
||||
|
||||
type PageStats struct {
|
||||
PagePath string `json:"page_path"`
|
||||
PageName string `json:"page_name"`
|
||||
ViewCount int64 `json:"view_count"`
|
||||
UniqueVisitors int64 `json:"unique_visitors"`
|
||||
}
|
||||
|
||||
var pages []PageStats
|
||||
|
||||
// Try to fetch from Umami first
|
||||
websiteID, err := ac.resolveWebsiteID()
|
||||
if err == nil && websiteID != "" {
|
||||
// Fetch URL metrics from Umami (last 30 days)
|
||||
endAt := time.Now().Unix() * 1000
|
||||
startAt := time.Now().AddDate(0, 0, -30).Unix() * 1000
|
||||
|
||||
metrics, err := ac.umamiService.GetWebsiteMetrics(websiteID, "url", startAt, endAt)
|
||||
if err == nil && metrics != nil {
|
||||
// Convert Umami metrics to PageStats format
|
||||
for i, metricMap := range metrics {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
pagePath := ""
|
||||
viewCount := int64(0)
|
||||
|
||||
if x, ok := metricMap["x"].(string); ok {
|
||||
pagePath = x
|
||||
}
|
||||
if y, ok := metricMap["y"].(float64); ok {
|
||||
viewCount = int64(y)
|
||||
}
|
||||
|
||||
pages = append(pages, PageStats{
|
||||
PagePath: pagePath,
|
||||
PageName: pagePath,
|
||||
ViewCount: viewCount,
|
||||
UniqueVisitors: viewCount, // Umami doesn't separate these
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, pages)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to internal analytics
|
||||
ac.DB.Model(&models.VisitorEvent{}).
|
||||
Where("event_type = ?", "page_view").
|
||||
Select("page_path, page_name, COUNT(*) as view_count, COUNT(DISTINCT ip_address) as unique_visitors").
|
||||
Group("page_path, page_name").
|
||||
Order("view_count DESC").
|
||||
Limit(limit).
|
||||
Scan(&pages)
|
||||
|
||||
c.JSON(http.StatusOK, pages)
|
||||
}
|
||||
|
||||
// GetTopArticles returns the most viewed articles
|
||||
func (ac *AnalyticsController) GetTopArticles(c *gin.Context) {
|
||||
limit := 10
|
||||
if l := c.Query("limit"); l != "" {
|
||||
fmt.Sscanf(l, "%d", &limit)
|
||||
}
|
||||
|
||||
var articles []models.Article
|
||||
ac.DB.Where("published = ?", true).
|
||||
Order("view_count DESC").
|
||||
Limit(limit).
|
||||
Find(&articles)
|
||||
|
||||
c.JSON(http.StatusOK, articles)
|
||||
}
|
||||
|
||||
type TopInteraction struct {
|
||||
Page string `json:"page"`
|
||||
Element string `json:"element"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
func (ctrl *AnalyticsController) GetTopInteractions(c *gin.Context) {
|
||||
daysParam := c.DefaultQuery("days", "30")
|
||||
limitParam := c.DefaultQuery("limit", "10")
|
||||
days, _ := strconv.Atoi(daysParam)
|
||||
if days <= 0 || days > 365 { days = 30 }
|
||||
limit, _ := strconv.Atoi(limitParam)
|
||||
if limit <= 0 || limit > 100 { limit = 10 }
|
||||
|
||||
start := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
var rows []TopInteraction
|
||||
err := ctrl.DB.
|
||||
Model(&models.VisitorEvent{}).
|
||||
Select("page, element, COUNT(*) as count").
|
||||
Where("event_type IN ? AND created_at >= ?", []string{"click", "interaction"}, start).
|
||||
Group("page, element").
|
||||
Order("count DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load interactions"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": rows})
|
||||
}
|
||||
Reference in New Issue
Block a user