在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。 1. 回答耗时不能用 updated_at - created_at(P0) GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。 实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。 于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」 和偏低的 24 小时回复占比都是这么来的。 修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时 写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at), 首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列), 否则历史值会随着后续的已读/置顶继续变大。 2. 环比句式读不通 中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。 改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。 3. 脚注说「当日 0 点的快照」不实 实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50, 基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。 4. 数据库故障时请求会排成 N×30s ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。 加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。 todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。 测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、 分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回 updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过 (internal/storage 的构建失败是 overlay 桩的既有问题)。
425 lines
13 KiB
Go
425 lines
13 KiB
Go
// 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 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
|
||
|
||
// 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,
|
||
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
|
||
}
|
||
// 用严格大于:并列时保留更早的一天,与 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
|
||
}
|