Files
tamakyi af39651734 fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」
原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
2026-10-24 22:39:47 +08:00

426 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}