fix(stats): 今日计数不再被每日缓存冻结,日期分组不依赖库会话时区

自查部署结果时发现两处正确性问题,都是本页上线时引入的:

1. 「今日提问 / 今日回答」原本随整页聚合一整天缓存,页面却写着「实时」,
   实际当天第一次访问后数字就冻住到次日。现拆出 TodayCounts 单独取,
   60 秒 TTL、跨日按日期失效;命中整页缓存时返回副本,不原地改缓存指针
   (那会被并发读踩成数据竞争)。

2. 日/月/时分组用 to_char/EXTRACT 直接作用在 timestamptz 上,结果跟着数据库
   会话时区走,而 PG 官方镜像默认 UTC、Go 侧按容器时区(Asia/Shanghai)切今天。
   实测同一条 2026-10-24 01:30+08 的提问,UTC 会话下被算成 10-23 17 点 ——
   日报表整体偏 8 小时、热力图时段错位,且与「今日」对不上。
   现统一经 analyticsLocalize() 加 AT TIME ZONE INTERVAL 换算,偏移取自
   time.Time.Zone()(Go 未设 TZ 时给 "Local",PG 不认该名,故不用 IANA 名)。
   过滤条件仍是裸列,索引照用。

另外两处顺手修:

- PeakDay 用 >= 比较,并列时会漂到最近的同值日期,与 busiestSlot 的取值规则不一致。
- 聚合改用 context.WithoutCancel + 30s 超时:第一个访客在聚合途中关掉页面时,
  排在后面等锁的请求不会各自重跑一遍全表 GROUP BY。

测试:新增 TestTodayNumbersNotFrozen(缓存命中后今日数仍更新、整页聚合当天只算一次)
与 TestTodayCacheExpiresAcrossMidnight;analytics 连真库通过。
This commit is contained in:
tamakyi
2026-10-24 01:20:02 +08:00
parent 64436d9d61
commit 0c282914c2
6 changed files with 294 additions and 43 deletions
+70
View File
@@ -5,6 +5,7 @@
package stats
import (
"context"
"strings"
"testing"
"time"
@@ -110,3 +111,72 @@ func TestOverviewDerived(t *testing.T) {
// 复用到句子里会读成「提问最集中在三 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)
}