在测试环境(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 桩的既有问题)。
281 lines
9.3 KiB
Go
281 lines
9.3 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"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/db"
|
|
)
|
|
|
|
// TestBuildDailyFillsGaps 没有数据的日期必须占位,否则折线会把两天连成一条直线,
|
|
// 让人以为「那天也有提问」。
|
|
func TestBuildDailyFillsGaps(t *testing.T) {
|
|
now := time.Date(2026, 10, 23, 15, 30, 0, 0, time.Local)
|
|
got := buildDaily([]db.CountPoint{{Key: "2026-10-20", N: 5}}, now, 5)
|
|
|
|
require.Len(t, got, 5)
|
|
require.Equal(t, "2026-10-19", got[0].Date)
|
|
require.Equal(t, "2026-10-23", got[4].Date)
|
|
require.EqualValues(t, 0, got[0].N)
|
|
require.EqualValues(t, 5, got[1].N, "10-20 是窗口里的第 2 天")
|
|
}
|
|
|
|
// TestBuildMonthlyNoMonthOverflow 月底不能溢出:3-31 直接 AddDate(0,-11,0) 会滚到 3 月初,
|
|
// 12 根柱子就变成 11 根。实现里先归到当月 1 号,这里守住这个行为。
|
|
func TestBuildMonthlyNoMonthOverflow(t *testing.T) {
|
|
now := time.Date(2026, 3, 31, 0, 0, 0, 0, time.Local)
|
|
got := buildMonthly(nil, now, 12)
|
|
|
|
require.Len(t, got, 12)
|
|
require.Equal(t, "2025-04", got[0].Month)
|
|
require.Equal(t, "2026-03", got[11].Month)
|
|
require.True(t, got[11].Current, "最后一根柱子是当月")
|
|
require.False(t, got[0].Current)
|
|
}
|
|
|
|
// TestBusiestSlot 同值时取更早的下标,保证同一份数据每次算出来的结论一致。
|
|
func TestBusiestSlot(t *testing.T) {
|
|
var heatmap [7][24]int64
|
|
heatmap[2][9] = 10
|
|
heatmap[5][20] = 10
|
|
|
|
wd, hr := busiestSlot(heatmap)
|
|
require.Equal(t, 2, wd)
|
|
require.Equal(t, 9, hr)
|
|
}
|
|
|
|
// TestOverviewEmptyNoDivideByZero 全零数据不能除零,也不能编出不存在的结论。
|
|
func TestOverviewEmptyNoDivideByZero(t *testing.T) {
|
|
v := buildOverview(&db.SiteAnalytics{}, time.Now())
|
|
|
|
require.Zero(t, v.AnswerRate)
|
|
require.Zero(t, v.FastReplyRate)
|
|
require.Zero(t, v.GrowthMoM)
|
|
require.False(t, v.HasPrevMonth)
|
|
require.Len(t, v.Daily, dailyWindowDays)
|
|
require.Len(t, v.Monthly, monthlyWindow)
|
|
|
|
page := NewPage("zh-CN", v)
|
|
require.Len(t, page.Insights, 1, "空库只说一句「数据还太少」")
|
|
require.Equal(t, "提问数据还太少,暂时看不出什么。", page.Insights[0])
|
|
}
|
|
|
|
// TestOverviewDerived 派生指标:回答率、环比、最忙时段、24 小时内回复占比。
|
|
func TestOverviewDerived(t *testing.T) {
|
|
now := time.Now()
|
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
prevMonth := monthStart.AddDate(0, -1, 0).Format("2006-01")
|
|
monthBefore := monthStart.AddDate(0, -2, 0).Format("2006-01")
|
|
|
|
raw := &db.SiteAnalytics{
|
|
TotalQuestions: 200, Answered: 50, Private: 20,
|
|
Anonymous: 120,
|
|
Daily: []db.CountPoint{{Key: now.Format("2006-01-02"), N: 7}},
|
|
// 环比看的是「上月」与「上上月」:本月还没过完,当年 10 月为 0 会把结论带偏。
|
|
Monthly: []db.CountPoint{
|
|
{Key: monthBefore, N: 100},
|
|
{Key: prevMonth, N: 50},
|
|
},
|
|
LengthBins: [6]int64{1, 2, 3, 4, 5, 6},
|
|
ReplyBins: [6]int64{10, 10, 10, 10, 5, 5},
|
|
}
|
|
raw.Heatmap[1][8] = 40
|
|
|
|
v := buildOverview(raw, now)
|
|
|
|
require.InDelta(t, 25.0, v.AnswerRate, 0.001)
|
|
require.InDelta(t, 60.0, v.FastReplyRate, 0.001, "(10+10+10)/50 在 24 小时内")
|
|
require.Equal(t, 180, int(v.Public))
|
|
require.Equal(t, 80, int(v.LoggedIn))
|
|
require.Equal(t, 1, v.BusiestWD)
|
|
require.Equal(t, 8, v.BusiestHr)
|
|
require.EqualValues(t, 40, v.HeatmapMax)
|
|
require.True(t, v.HasPrevMonth)
|
|
require.InDelta(t, -50.0, v.GrowthMoM, 0.001, "9 月 50 相对 8 月 100 是 -50%")
|
|
|
|
page := NewPage("zh-CN", v)
|
|
require.NotEmpty(t, page.Insights)
|
|
require.Len(t, page.WeekdayLabels, 7)
|
|
require.Len(t, page.LengthLabels, 6)
|
|
require.Len(t, page.ReplyLabels, 6)
|
|
|
|
// 句子里必须是完整星期名:热力图列头的 stats.wd.* 是单字("一"/"三"),
|
|
// 复用到句子里会读成「提问最集中在三 8 点前后」。
|
|
require.Contains(t, strings.Join(page.Insights, "\n"), "提问最集中在周一 8 点前后。")
|
|
}
|
|
|
|
// fakeAnalytics 最小实现,用来卡住缓存的边界:
|
|
// 整页聚合一天只该算一次,但「今日」两项必须跟着每次请求走。
|
|
type fakeAnalytics struct {
|
|
todayQ, todayA int64
|
|
siteCalls int
|
|
}
|
|
|
|
func (f *fakeAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) {
|
|
f.siteCalls++
|
|
return &db.SiteAnalytics{TotalQuestions: 10, Answered: 4}, nil
|
|
}
|
|
|
|
func (f *fakeAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) {
|
|
return f.todayQ, f.todayA, nil
|
|
}
|
|
|
|
// TestTodayNumbersNotFrozen 回归:「今日」两项曾被塞进每日缓存,
|
|
// 导致页面顶着「实时」的说明一整天显示常量。
|
|
func TestTodayNumbersNotFrozen(t *testing.T) {
|
|
prev := db.Analytics
|
|
t.Cleanup(func() {
|
|
db.Analytics = prev
|
|
InvalidateOverview()
|
|
})
|
|
|
|
f := &fakeAnalytics{todayQ: 3, todayA: 1}
|
|
db.Analytics = f
|
|
InvalidateOverview()
|
|
|
|
first, err := OverviewData(context.Background())
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 3, first.TodayQuestions)
|
|
|
|
// 今天又来了几条,且短缓存已过期
|
|
f.todayQ, f.todayA = 8, 2
|
|
todayMu.Lock()
|
|
todayAt = todayAt.Add(-2 * todayTTL)
|
|
todayMu.Unlock()
|
|
|
|
second, err := OverviewData(context.Background())
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 8, second.TodayQuestions, "今日数不能被每日缓存冻结")
|
|
require.EqualValues(t, 2, second.TodayAnswers)
|
|
require.Equal(t, 1, f.siteCalls, "整页聚合当天只应算一次")
|
|
|
|
// 返回的是副本,不能把已经交出去的 pages 改掉
|
|
require.EqualValues(t, 3, first.TodayQuestions)
|
|
}
|
|
|
|
// TestTodayCacheExpiresAcrossMidnight 跨日必须重算:昨天 23:59 算出的是昨天的数。
|
|
func TestTodayCacheExpiresAcrossMidnight(t *testing.T) {
|
|
prev := db.Analytics
|
|
t.Cleanup(func() {
|
|
db.Analytics = prev
|
|
InvalidateOverview()
|
|
})
|
|
|
|
f := &fakeAnalytics{todayQ: 7, todayA: 5}
|
|
db.Analytics = f
|
|
|
|
justAfterMidnight := time.Date(2026, 10, 24, 0, 0, 30, 0, time.Local)
|
|
// 昨天午夜算出来的缓存(日期不同,即使还在 60 秒内也不该复用)
|
|
setTodayCache(99, 99, justAfterMidnight.Add(-24*time.Hour))
|
|
|
|
q, a := todayNumbers(context.Background(), justAfterMidnight, 0, 0)
|
|
require.EqualValues(t, 7, q, "跨日后必须重算,不能沿用昨天的数字")
|
|
require.EqualValues(t, 5, a)
|
|
}
|
|
|
|
// failingAnalytics 模拟数据库挂掉:每次调用都报错,并记录被调用次数。
|
|
type failingAnalytics struct {
|
|
siteCalls int
|
|
}
|
|
|
|
func (f *failingAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) {
|
|
f.siteCalls++
|
|
return nil, errors.New("db is down")
|
|
}
|
|
|
|
func (f *failingAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) {
|
|
return 0, 0, errors.New("db is down")
|
|
}
|
|
|
|
// TestOverviewFailureCooldown 聚合失败后要冷却,不能每个请求都重排一次全表聚合。
|
|
//
|
|
// 没有冷却时,数据库挂起(连接不断、查询不返回)会让排队请求各自耗满 30 秒超时,
|
|
// 第 N 个请求要等 N×30 秒 —— 页面对所有人一起超时。
|
|
func TestOverviewFailureCooldown(t *testing.T) {
|
|
prev := db.Analytics
|
|
t.Cleanup(func() {
|
|
db.Analytics = prev
|
|
InvalidateOverview()
|
|
})
|
|
|
|
f := &failingAnalytics{}
|
|
db.Analytics = f
|
|
InvalidateOverview()
|
|
|
|
_, err := OverviewData(context.Background())
|
|
require.Error(t, err, "没有任何快照时应该把错误交给调用方")
|
|
|
|
// 冷却期内的请求直接失败,不再打库。
|
|
for i := 0; i < 5; i++ {
|
|
_, err := OverviewData(context.Background())
|
|
require.Error(t, err)
|
|
}
|
|
require.Equal(t, 1, f.siteCalls, "冷却期内不该重复聚合")
|
|
}
|
|
|
|
// TestOverviewCooldownClearedOnSuccess 冷却只针对失败,成功后照常刷新。
|
|
func TestOverviewCooldownClearedOnSuccess(t *testing.T) {
|
|
prev := db.Analytics
|
|
t.Cleanup(func() {
|
|
db.Analytics = prev
|
|
InvalidateOverview()
|
|
})
|
|
|
|
bad := &failingAnalytics{}
|
|
db.Analytics = bad
|
|
InvalidateOverview()
|
|
_, err := OverviewData(context.Background())
|
|
require.Error(t, err)
|
|
|
|
// 数据库恢复
|
|
good := &fakeAnalytics{todayQ: 1, todayA: 1}
|
|
db.Analytics = good
|
|
// 手动把冷却时间挪到过去,模拟「过了 30 秒」
|
|
ovMu.Lock()
|
|
ovFailAt = time.Now().Add(-2 * ovFailCooldown)
|
|
ovMu.Unlock()
|
|
|
|
v, err := OverviewData(context.Background())
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 10, v.TotalQuestions)
|
|
require.Equal(t, 1, good.siteCalls)
|
|
|
|
// 成功后冷却状态要清掉,否则下次失败判断会用到旧时间
|
|
ovMu.Lock()
|
|
failAt, failErr := ovFailAt, ovFailErr
|
|
ovMu.Unlock()
|
|
require.True(t, failAt.IsZero(), "成功后必须清掉失败时间")
|
|
require.NoError(t, failErr)
|
|
}
|
|
|
|
// TestTodayFailureStillThrottled 数据库挂掉时「今日」两项也不能每个请求都打两条 COUNT。
|
|
func TestTodayFailureStillThrottled(t *testing.T) {
|
|
prev := db.Analytics
|
|
t.Cleanup(func() {
|
|
db.Analytics = prev
|
|
InvalidateOverview()
|
|
})
|
|
|
|
db.Analytics = &failingAnalytics{}
|
|
InvalidateOverview()
|
|
|
|
now := time.Now()
|
|
q, a := todayNumbers(context.Background(), now, 7, 3)
|
|
require.EqualValues(t, 7, q, "失败时回退到调用方给的值")
|
|
require.EqualValues(t, 3, a)
|
|
|
|
// 紧接一次:命中失败缓存,不该再打库(打了也没法区分,直接看返回值仍是回退值)
|
|
q, a = todayNumbers(context.Background(), now.Add(time.Second), 0, 0)
|
|
require.EqualValues(t, 7, q, "冷却期内沿用上一次的结果,不再打库")
|
|
require.EqualValues(t, 3, a)
|
|
}
|