Files
tamabox/internal/db/analytics_test.go
T
tamakyi 0c282914c2 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 连真库通过。
2026-10-24 01:20:02 +08:00

140 lines
4.3 KiB
Go
Raw 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 db
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestSiteAnalytics 验证 /stats 页的聚合口径。
//
// 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL
// (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)),
// 对着 mock 断言等于什么都没验证。
func TestSiteAnalytics(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
now := time.Now()
store := NewSiteAnalyticsStore(db)
questions := NewQuestionsStore(db)
require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "[email protected]"}).Error)
// 一条登录用户提的、已回答的长问题
answered, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7,
})
require.NoError(t, err)
require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案"))
// 一条匿名提的私密短问题,未读未答
_, err = questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true,
})
require.NoError(t, err)
// 一条被软删除的:不该出现在任何数字里
deleted, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3",
})
require.NoError(t, err)
require.NoError(t, questions.DeleteByID(ctx, deleted.ID))
got, err := store.SiteAnalytics(ctx, now)
require.NoError(t, err)
// 计数
require.EqualValues(t, 1, got.RegisteredUsers)
require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数")
require.EqualValues(t, 1, got.Answered)
require.EqualValues(t, 1, got.Private)
require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读")
require.EqualValues(t, 1, got.Anonymous)
require.EqualValues(t, 1, got.ActiveBoxes)
require.EqualValues(t, 1, got.AnsweringBoxes)
// 今日(提问是刚才写的,必然落在今天)—— 单独取,不随整页聚合缓存
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayQ, todayA, err := store.TodayCounts(ctx, dayStart)
require.NoError(t, err)
require.EqualValues(t, 2, todayQ)
require.EqualValues(t, 1, todayA)
// 日/月趋势里能找到今天与本月
today := now.Format("2006-01-02")
require.EqualValues(t, 2, countOf(got.Daily, today))
require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01")))
// 热力图:两条提问都发生在当前这一小时
dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0
require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()])
// 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0)
require.EqualValues(t, 1, got.LengthBins[0])
require.EqualValues(t, 1, got.LengthBins[3])
// 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入
require.EqualValues(t, 1, got.ReplyBins[0])
var replyTotal int64
for _, n := range got.ReplyBins {
replyTotal += n
}
require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问")
// 最后时间戳
require.NotNil(t, got.LastQuestionAt)
require.NotNil(t, got.LastAnswerAt)
}
// TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。
func TestSiteAnalyticsEmpty(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now())
require.NoError(t, err)
require.Zero(t, got.TotalQuestions)
require.Zero(t, got.RegisteredUsers)
require.Nil(t, got.LastQuestionAt)
require.Nil(t, got.LastAnswerAt)
var heatTotal int64
for _, row := range got.Heatmap {
for _, n := range row {
heatTotal += n
}
}
require.Zero(t, heatTotal)
}
func countOf(points []CountPoint, key string) int64 {
for _, p := range points {
if p.Key == key {
return p.N
}
}
return 0
}