package api import ( "fmt" "net/http" "sort" "strconv" "strings" "sync" "time" "home-vue-go/internal/analytics" "home-vue-go/internal/config" "home-vue-go/internal/database" "home-vue-go/internal/ent/visit" "github.com/gin-gonic/gin" ) type analyticsCache struct { mu sync.Mutex statsExpires time.Time chartsExpires time.Time statsKey string chartsKey string stats gin.H trend []gin.H sources []gin.H } var dashboardAnalyticsCache analyticsCache var umamiClient = analytics.NewClient() func GetStats(db *database.Database, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { settings, err := db.LoadSiteSettings(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) return } siteCount, _ := db.Client.Site.Query().Count(c.Request.Context()) if settings.AnalyticsProvider == "umami" { stats, status, err := loadUmamiStats(c, settings, siteCount, cfg) if err != nil { c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"}) return } c.JSON(http.StatusOK, stats) return } c.JSON(http.StatusOK, localStats(c, db, siteCount)) } } func GetChartData(db *database.Database, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { settings, err := db.LoadSiteSettings(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) return } periodDays := chartPeriodDays(c.DefaultQuery("period", "7")) if settings.AnalyticsProvider == "umami" { trend, sources, status, err := loadUmamiCharts(c, settings, periodDays, cfg) if err != nil { c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"}) return } c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "umami", "analyticsConfigured": true, "analyticsError": ""}) return } trend, sources := localCharts(c, db, periodDays) c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""}) } } func GetRecentVisits(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { settings, err := db.LoadSiteSettings(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) return } if settings.AnalyticsProvider == "umami" { c.JSON(http.StatusOK, []gin.H{}) return } limit := 5 if value, err := strconv.Atoi(c.DefaultQuery("limit", "5")); err == nil && value > 0 { limit = value if limit > 50 { limit = 50 } } records, err := queryVisits(c, db, 0) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "加载访问记录失败"}) return } result := make([]gin.H, 0, min(limit, len(records))) now := time.Now() for index := len(records) - 1; index >= 0 && len(result) < limit; index-- { record := records[index] result = append(result, gin.H{"path": record.Path, "ip": record.IP, "time": relativeVisitTime(now, record.VisitTime)}) } c.JSON(http.StatusOK, result) } } func NotifyConfigUpdate() gin.HandlerFunc { return func(c *gin.Context) { resetAnalyticsCache() c.JSON(http.StatusOK, gin.H{"message": "配置更新通知已发送"}) } } func loadUmamiStats(c *gin.Context, settings *config.SiteSettings, siteCount int, appConfig *config.Config) (gin.H, int, error) { credential, err := decryptUmamiCredential(settings, appConfig) if err != nil { return nil, http.StatusBadGateway, err } cacheKey := "stats:" + settings.UmamiWebsiteID + ":" + settings.UmamiAPIURL dashboardAnalyticsCache.mu.Lock() if dashboardAnalyticsCache.statsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.statsExpires) && dashboardAnalyticsCache.stats != nil { value := dashboardAnalyticsCache.stats dashboardAnalyticsCache.mu.Unlock() return value, http.StatusOK, nil } dashboardAnalyticsCache.mu.Unlock() clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID} now := time.Now() allTime, err := umamiClient.GetStats(c.Request.Context(), clientConfig, time.Unix(0, 0), now) if err != nil { return nil, http.StatusBadGateway, err } today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) todayStats, err := umamiClient.GetStats(c.Request.Context(), clientConfig, today, now) if err != nil { return nil, http.StatusBadGateway, err } active, err := umamiClient.GetActive(c.Request.Context(), clientConfig) if err != nil { return nil, http.StatusBadGateway, err } result := gin.H{"totalViews": allTime.Pageviews, "uniqueVisitors": allTime.Visitors, "todayViews": todayStats.Pageviews, "activeVisitors": active, "totalSites": siteCount, "analyticsSource": "umami", "analyticsConfigured": true} dashboardAnalyticsCache.mu.Lock() dashboardAnalyticsCache.statsKey, dashboardAnalyticsCache.statsExpires, dashboardAnalyticsCache.stats = cacheKey, time.Now().Add(30*time.Second), result dashboardAnalyticsCache.mu.Unlock() return result, http.StatusOK, nil } func loadUmamiCharts(c *gin.Context, settings *config.SiteSettings, periodDays int, appConfig *config.Config) ([]gin.H, []gin.H, int, error) { credential, err := decryptUmamiCredential(settings, appConfig) if err != nil { return nil, nil, http.StatusBadGateway, err } cacheKey := fmt.Sprintf("charts:%s:%d:%s", settings.UmamiWebsiteID, periodDays, settings.UmamiAPIURL) dashboardAnalyticsCache.mu.Lock() if dashboardAnalyticsCache.chartsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.chartsExpires) && dashboardAnalyticsCache.trend != nil { trend, sources := dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources dashboardAnalyticsCache.mu.Unlock() return trend, sources, http.StatusOK, nil } dashboardAnalyticsCache.mu.Unlock() clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID} now := time.Now() start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -periodDays+1) pageviews, err := umamiClient.GetPageviews(c.Request.Context(), clientConfig, start, now) if err != nil { return nil, nil, http.StatusBadGateway, err } referrers, err := umamiClient.GetMetrics(c.Request.Context(), clientConfig, start, now, "referrer") if err != nil { return nil, nil, http.StatusBadGateway, err } trend := make([]gin.H, 0, len(pageviews)) for _, point := range pageviews { trend = append(trend, gin.H{"label": point.X, "value": point.Y}) } sources := percentageSources(referrers) dashboardAnalyticsCache.mu.Lock() dashboardAnalyticsCache.chartsKey, dashboardAnalyticsCache.chartsExpires, dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources = cacheKey, time.Now().Add(30*time.Second), trend, sources dashboardAnalyticsCache.mu.Unlock() return trend, sources, http.StatusOK, nil } func localStats(c *gin.Context, db *database.Database, siteCount int) gin.H { records, err := queryVisits(c, db, 0) if err != nil { return gin.H{"totalViews": 0, "uniqueVisitors": 0, "todayViews": 0, "activeVisitors": 0, "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": "query_failed"} } now := time.Now() start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) unique := map[string]struct{}{} active := map[string]struct{}{} todayViews := 0 for _, record := range records { key := record.IP + "|" + record.UserAgent unique[key] = struct{}{} if !record.VisitTime.Before(start) { todayViews++ } if !record.VisitTime.Before(now.Add(-5 * time.Minute)) { active[key] = struct{}{} } } return gin.H{"totalViews": len(records), "uniqueVisitors": len(unique), "todayViews": todayViews, "activeVisitors": len(active), "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""} } func localCharts(c *gin.Context, db *database.Database, periodDays int) ([]gin.H, []gin.H) { records, err := queryVisits(c, db, 0) if err != nil { return emptyTrend(periodDays), []gin.H{{"label": "直接访问", "value": 100}} } now := time.Now() trend := make([]gin.H, 0, periodDays) for index := 0; index < periodDays; index++ { date := now.AddDate(0, 0, -(periodDays - 1 - index)) dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) dayEnd := dayStart.AddDate(0, 0, 1) count := 0 for _, record := range records { if !record.VisitTime.Before(dayStart) && record.VisitTime.Before(dayEnd) { count++ } } trend = append(trend, gin.H{"label": date.Format("1/2"), "value": count}) } counts := make(map[string]int) for _, record := range records { counts[classifyReferer(record.Referer)]++ } sources := percentageCounts(counts, len(records)) return trend, sources } func queryVisits(c *gin.Context, db *database.Database, days int) ([]*structVisit, error) { query := db.Client.Visit.Query().Order(visit.ByVisitTime()) if days > 0 { query = query.Where(visit.VisitTimeGTE(time.Now().AddDate(0, 0, -days))) } rows, err := query.All(c.Request.Context()) if err != nil { return nil, err } result := make([]*structVisit, len(rows)) for index, row := range rows { result[index] = &structVisit{Path: row.Path, IP: row.IP, UserAgent: row.UserAgent, Referer: row.Referer, VisitTime: row.VisitTime} } return result, nil } type structVisit struct { Path, IP, UserAgent, Referer string VisitTime time.Time } func percentageSources(points []analytics.Point) []gin.H { counts := make(map[string]int) for _, point := range points { counts[classifyReferer(point.X)] += point.Y } total := 0 for _, count := range counts { total += count } return percentageCounts(counts, total) } func percentageCounts(counts map[string]int, total int) []gin.H { if total <= 0 { return []gin.H{{"label": "直接访问", "value": 100}} } type item struct { label string count, value, remainder int } items := make([]item, 0, len(counts)) used := 0 for label, count := range counts { value := count * 100 / total items = append(items, item{label: label, count: count, value: value, remainder: count * 100 % total}) used += value } sort.Slice(items, func(i, j int) bool { if items[i].remainder != items[j].remainder { return items[i].remainder > items[j].remainder } return items[i].label < items[j].label }) for index := 0; index < 100-used && len(items) > 0; index++ { items[index%len(items)].value++ } sort.Slice(items, func(i, j int) bool { if items[i].count != items[j].count { return items[i].count > items[j].count } return items[i].label < items[j].label }) result := make([]gin.H, 0, len(items)) for _, item := range items { result = append(result, gin.H{"label": item.label, "value": item.value}) } return result } func classifyReferer(referrer string) string { referrer = strings.ToLower(strings.TrimSpace(referrer)) if referrer == "" { return "直接访问" } for _, source := range []string{"google", "baidu", "bing", "yahoo", "sogou"} { if strings.Contains(referrer, source) { return "搜索引擎" } } for _, source := range []string{"twitter", "facebook", "weibo", "wechat", "qq"} { if strings.Contains(referrer, source) { return "社交媒体" } } return "其他" } func decryptUmamiCredential(settings *config.SiteSettings, appConfig *config.Config) (string, error) { if settings.UmamiWebsiteID == "" || settings.UmamiCredential == "" { return "", fmt.Errorf("Umami配置不完整") } if appConfig == nil { return "", fmt.Errorf("统计加密配置不可用") } return appConfig.DecryptSecret(settings.UmamiCredential) } func resetAnalyticsCache() { dashboardAnalyticsCache.mu.Lock() dashboardAnalyticsCache.statsKey = "" dashboardAnalyticsCache.chartsKey = "" dashboardAnalyticsCache.statsExpires = time.Time{} dashboardAnalyticsCache.chartsExpires = time.Time{} dashboardAnalyticsCache.stats = nil dashboardAnalyticsCache.trend = nil dashboardAnalyticsCache.sources = nil dashboardAnalyticsCache.mu.Unlock() } func chartPeriodDays(value string) int { switch value { case "30": return 30 case "90": return 90 default: return 7 } } func emptyTrend(days int) []gin.H { result := make([]gin.H, days) for index := range result { result[index] = gin.H{"label": "", "value": 0} } return result } func relativeVisitTime(now, visited time.Time) string { diff := now.Sub(visited) switch { case diff < time.Minute: return "刚刚" case diff < time.Hour: return fmt.Sprintf("%.0f分钟前", diff.Minutes()) case diff < 24*time.Hour: return fmt.Sprintf("%.0f小时前", diff.Hours()) default: return fmt.Sprintf("%.0f天前", diff.Hours()/24) } } func min(left, right int) int { if left < right { return left } return right }