自查部署结果时发现两处正确性问题,都是本页上线时引入的: 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 连真库通过。
140 lines
4.3 KiB
Go
140 lines
4.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 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
|
||
}
|