// 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 时不展示) 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 是这份快照的生成时间,只覆盖累计数字与图表;今日两项在缓存命中时 // 会被 withToday 换成更晚的读数,所以它不能当成「整页数据的时间」来展示。 UpdatedAt 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 // ovFailAt / ovFailErr 记录最近一次聚合失败。 // // 没有它的话,数据库挂起时(连接不断但查询不返回)每个排队的请求都要各自耗满 // 30 秒超时:第 N 个请求要等 N×30 秒,页面对所有人一起超时。 // 冷却期内直接返回失败(有旧快照就先用旧的),不再碰数据库。 ovFailAt time.Time ovFailErr error // todayMu 保护「今日」两项的短缓存。 todayMu sync.Mutex todayQ int64 todayA int64 todayAt time.Time todayDate string ) // todayTTL 「今日」两项的缓存时长。 // // 不长于 60 秒:页面写的是「实时」,但也不能让每次刷新都打两条 COUNT, // 否则一个爱刷新的访客就能持续骚扰数据库。 const todayTTL = 60 * time.Second // OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。 // // 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天); // 完全没有可用结果才返回错误。 func OverviewData(ctx context.Context) (*Overview, error) { now := time.Now() today := now.Format("2006-01-02") ovMu.Lock() cached, day, failAt, failErr := ovCached, ovDay, ovFailAt, ovFailErr ovMu.Unlock() if cached != nil && day == today { // 命中时也要刷新「今日」两项:它们不属于每日缓存的内容。 return withToday(ctx, cached, now), nil } if inCooldown(now, failAt) { return degraded(cached, failErr) } ovRefresh.Lock() defer ovRefresh.Unlock() // 二次检查:等锁期间可能已有别的请求刷新过,或是刚失败过。 ovMu.Lock() cached, day, failAt, failErr = ovCached, ovDay, ovFailAt, ovFailErr ovMu.Unlock() if cached != nil && day == today { return withToday(ctx, cached, now), nil } if inCooldown(now, failAt) { return degraded(cached, failErr) } if db.Analytics == nil { // 单测里没有初始化数据库时不应 panic,交给调用方回退。 return nil, errNoDatabase } // 聚合用一条独立于请求的 context:第一个访客在聚合途中关掉页面时, // 请求 ctx 会被取消,排在后面等锁的请求就会各自重跑一遍全表 GROUP BY // (1C1G 上等于连环超时)。WithoutCancel 保留 trace 等 value、只摘掉取消信号。 aggCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() raw, err := db.Analytics.SiteAnalytics(aggCtx, now) if err != nil { logrus.WithContext(aggCtx).WithError(err).Warn("计算全站统计数据失败") ovMu.Lock() ovFailAt, ovFailErr = now, err ovMu.Unlock() if cached != nil { return withToday(ctx, cached, now), nil } return nil, err } v := buildOverview(raw, now) // 今日两项单独取(60 秒缓存),不进整天复用的那份快照。 v.TodayQuestions, v.TodayAnswers = todayNumbers(ctx, now, 0, 0) ovMu.Lock() ovCached = v ovDay = today ovFailAt, ovFailErr = time.Time{}, nil ovMu.Unlock() return v, nil } // ovFailCooldown 聚合失败后多久内不再重试。 // // 取 30 秒:够数据库扛过一次抖动,又能让恢复后的第一个请求在一分钟内拿到新数据。 const ovFailCooldown = 30 * time.Second func inCooldown(now, failAt time.Time) bool { return !failAt.IsZero() && now.Sub(failAt) < ovFailCooldown } // degraded 按「有旧快照就用旧的」降级:页面照常打开,只是数据停在上一份。 func degraded(cached *Overview, err error) (*Overview, error) { if cached != nil { return cached, nil } if err == nil { err = errNoDatabase } return nil, err } // withToday 返回一个「今日」两项已更新到最新的副本。 // // 必须拷贝而不是原地改 ovCached:缓存里的指针会被多个 goroutine 同时读, // 原地写字段就是数据竞争。Daily / Monthly / Heatmap 都是只读共享,浅拷贝安全。 func withToday(ctx context.Context, base *Overview, now time.Time) *Overview { q, a := todayNumbers(ctx, now, base.TodayQuestions, base.TodayAnswers) if q == base.TodayQuestions && a == base.TodayAnswers { return base } out := *base out.TodayQuestions, out.TodayAnswers = q, a return &out } // todayNumbers 取「今日提问 / 今日回答」,带 60 秒缓存与跨日失效。 // 查询失败时优先沿用上次的已知值,再退到调用方给的回退值 —— 页面不该因为 // 两条 COUNT 失败就打不开。 func todayNumbers(ctx context.Context, now time.Time, fallbackQ, fallbackA int64) (int64, int64) { date := now.Format("2006-01-02") todayMu.Lock() q, a, at, d := todayQ, todayA, todayAt, todayDate todayMu.Unlock() // 跨日必须先失效:昨天 23:59:50 算出的「今日」在 00:00:30 仍在 TTL 内。 if d == date && !at.IsZero() && now.Sub(at) < todayTTL { return q, a } if db.Analytics == nil { return fallbackQ, fallbackA } nq, na, err := db.Analytics.TodayCounts(ctx, time.Date( now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) if err != nil { logrus.WithContext(ctx).WithError(err).Warn("读取今日统计失败") // 失败也要盖时间戳:数据库故障时每个请求都打两条 COUNT 只会雪上加霜。 // 期间沿用同一天的旧值,跨日时用调用方给的回退值(昨天的数不是今天的数)。 if !at.IsZero() && d == date { setTodayCache(q, a, now) return q, a } setTodayCache(fallbackQ, fallbackA, now) return fallbackQ, fallbackA } setTodayCache(nq, na, now) return nq, na } func setTodayCache(q, a int64, now time.Time) { todayMu.Lock() todayQ, todayA, todayAt, todayDate = q, a, now, now.Format("2006-01-02") todayMu.Unlock() } // InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。 func InvalidateOverview() { ovMu.Lock() ovCached = nil ovDay = "" ovFailAt, ovFailErr = time.Time{}, nil ovMu.Unlock() // 「今日」两项也要一起丢:留着会让紧随其后的请求在 60 秒内继续看到旧值。 todayMu.Lock() todayQ, todayA, todayAt, todayDate = 0, 0, time.Time{}, "" todayMu.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, Heatmap: raw.Heatmap, HeatmapDays: raw.HeatmapDays, LengthBins: raw.LengthBins, ReplyBins: raw.ReplyBins, } 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 } // 用严格大于:并列时保留更早的一天,与 busiestSlot 的取值规则一致 // (>= 会让「最忙的一天」随更新漂移到最近一个同值日期)。 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 }