// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package stats import ( "context" "sync" "time" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/db" ) // Overview 是「全站数据」页的展示模型。 // // 与 View 的区别:View 是首页那张三行小卡片的口径(30 分钟缓存、只有三个累计数), // Overview 是完整聚合(趋势、构成、热力图、内容分布),按自然日缓存 —— 也就是 // 「每天更新一次」:当天第一次访问重算,之后全站共用同一份结果。 type Overview struct { // 累计概览 RegisteredUsers int64 TotalQuestions int64 Answered int64 Public int64 Private int64 Read int64 Unread int64 Anonymous int64 LoggedIn int64 ActiveBoxes int64 AnsweringBoxes int64 TotalMessages int64 LastQuestionAt string LastAnswerAt string AnswerRate float64 // 已回答 / 总提问,百分比 FastReplyRate float64 // 24 小时内获得回答的比例,百分比 Total30 int64 // 近 30 天提问总数 AvgDailyLast30 float64 // 近 30 天日均提问 GrowthMoM float64 // 本月提问量相对上月的环比,百分比(上月为 0 时置 0) HasPrevMonth bool // 上月是否有数据,决定要不要展示环比 // 今日实时(不参与每日缓存) TodayQuestions int64 TodayAnswers int64 // 趋势(已补齐空日期,值为 0 的日期也占位,避免折线跳过) Daily []DailyPoint Monthly []MonthlyPoint DailyMax int64 MonthlyMax int64 PeakDay DailyPoint BusiestWD int // 0=周日 BusiestHr int // 0-23 // 热力图 Heatmap [7][24]int64 HeatmapMax int64 HeatmapDays int // 内容分布 LengthBins [6]int64 ReplyBins [6]int64 // 元信息 UpdatedAt string DataDate string } // DailyPoint 某一天的提问量。 type DailyPoint struct { Date string // 2026-10-01 Label string // 10-01 N int64 } // MonthlyPoint 某个月的提问量。 type MonthlyPoint struct { Month string // 2026-10 Label string // 10 N int64 Current bool // 是否为当月(页面高亮最后一根柱子) } const ( dailyWindowDays = 30 monthlyWindow = 12 ) var ( ovMu sync.Mutex ovCached *Overview ovDay string // ovRefresh 串行化重算:一天的第一个请求承担全部聚合,其余请求等它算完直接复用, // 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。 ovRefresh sync.Mutex ) // OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。 // // 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天); // 完全没有可用结果才返回错误。 func OverviewData(ctx context.Context) (*Overview, error) { today := time.Now().Format("2006-01-02") ovMu.Lock() cached, day := ovCached, ovDay ovMu.Unlock() if cached != nil && day == today { return cached, nil } ovRefresh.Lock() defer ovRefresh.Unlock() // 二次检查:等锁期间可能已有别的请求刷新过。 ovMu.Lock() cached, day = ovCached, ovDay ovMu.Unlock() if cached != nil && day == today { return cached, nil } if db.Analytics == nil { // 单测里没有初始化数据库时不应 panic,交给调用方回退。 return nil, errNoDatabase } now := time.Now() raw, err := db.Analytics.SiteAnalytics(ctx, now) if err != nil { logrus.WithContext(ctx).WithError(err).Warn("计算全站统计数据失败") if cached != nil { return cached, nil } return nil, err } v := buildOverview(raw, now) ovMu.Lock() ovCached = v ovDay = today ovMu.Unlock() return v, nil } // InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。 func InvalidateOverview() { ovMu.Lock() ovCached = nil ovDay = "" ovMu.Unlock() } var errNoDatabase = errString("database not initialized") type errString string func (e errString) Error() string { return string(e) } func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview { v := &Overview{ RegisteredUsers: raw.RegisteredUsers, TotalQuestions: raw.TotalQuestions, Answered: raw.Answered, Public: raw.TotalQuestions - raw.Private, Private: raw.Private, Read: raw.Read, Unread: raw.TotalQuestions - raw.Read, Anonymous: raw.Anonymous, LoggedIn: raw.TotalQuestions - raw.Anonymous, ActiveBoxes: raw.ActiveBoxes, AnsweringBoxes: raw.AnsweringBoxes, TotalMessages: raw.TotalMessages, TodayQuestions: raw.TodayQuestions, TodayAnswers: raw.TodayAnswers, Heatmap: raw.Heatmap, HeatmapDays: raw.HeatmapDays, LengthBins: raw.LengthBins, ReplyBins: raw.ReplyBins, DataDate: now.Format("2006-01-02"), } if v.Public < 0 { v.Public = 0 } if v.Unread < 0 { v.Unread = 0 } if v.LoggedIn < 0 { v.LoggedIn = 0 } if raw.LastQuestionAt != nil { v.LastQuestionAt = raw.LastQuestionAt.Format("2006-01-02 15:04:05") } if raw.LastAnswerAt != nil { v.LastAnswerAt = raw.LastAnswerAt.Format("2006-01-02 15:04:05") } if v.TotalQuestions > 0 { v.AnswerRate = percent(v.Answered, v.TotalQuestions) } if answered := v.Answered; answered > 0 { v.FastReplyRate = percent(v.ReplyBins[0]+v.ReplyBins[1]+v.ReplyBins[2], answered) } v.Daily = buildDaily(raw.Daily, now, dailyWindowDays) v.Monthly = buildMonthly(raw.Monthly, now, monthlyWindow) for _, p := range v.Daily { if p.N > v.DailyMax { v.DailyMax = p.N } if p.N >= v.PeakDay.N { v.PeakDay = p } } var dailySum int64 for _, p := range v.Daily { dailySum += p.N } v.Total30 = dailySum v.AvgDailyLast30 = float64(dailySum) / float64(dailyWindowDays) for _, p := range v.Monthly { if p.N > v.MonthlyMax { v.MonthlyMax = p.N } } // 环比取「上月 vs 上上月」而不是「本月 vs 上月」:本月通常还没过完, // 拿一个残月去比完整月份,每月 1 号都会得出 -95% 这种毫无意义的结论。 if n := len(v.Monthly); n >= 3 && v.Monthly[n-3].N > 0 { prev := float64(v.Monthly[n-3].N) v.HasPrevMonth = true v.GrowthMoM = (float64(v.Monthly[n-2].N) - prev) / prev * 100 } for _, row := range v.Heatmap { for _, n := range row { if n > v.HeatmapMax { v.HeatmapMax = n } } } v.BusiestWD, v.BusiestHr = busiestSlot(v.Heatmap) v.UpdatedAt = now.Format("2006-01-02 15:04:05") return v } // buildDaily 把「只有有数据的那几天」补齐成连续 N 天,折线才不会在无数据的日期断开。 func buildDaily(raw []db.CountPoint, now time.Time, days int) []DailyPoint { index := make(map[string]int64, len(raw)) for _, p := range raw { index[p.Key] = p.N } start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()). AddDate(0, 0, -(days - 1)) out := make([]DailyPoint, 0, days) for i := 0; i < days; i++ { d := start.AddDate(0, 0, i) key := d.Format("2006-01-02") out = append(out, DailyPoint{Date: key, Label: d.Format("01-02"), N: index[key]}) } return out } func buildMonthly(raw []db.CountPoint, now time.Time, months int) []MonthlyPoint { index := make(map[string]int64, len(raw)) for _, p := range raw { index[p.Key] = p.N } start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()). AddDate(0, -(months - 1), 0) out := make([]MonthlyPoint, 0, months) for i := 0; i < months; i++ { m := start.AddDate(0, i, 0) key := m.Format("2006-01") out = append(out, MonthlyPoint{Month: key, Label: m.Format("01"), N: index[key], Current: i == months-1}) } return out } // busiestSlot 找出提问最集中的「星期 × 小时」。同值时取更早的下标,结果稳定可复现。 func busiestSlot(heatmap [7][24]int64) (weekday, hour int) { best := int64(-1) for d := 0; d < 7; d++ { for h := 0; h < 24; h++ { if heatmap[d][h] > best { best = heatmap[d][h] weekday, hour = d, h } } } return weekday, hour } func percent(part, total int64) float64 { if total <= 0 { return 0 } return float64(part) / float64(total) * 100 }