`TodayCounts` 的今日回答数用 `updated_at >= 0 点` 过滤。GORM 的 Update 一律刷新 updated_at,于是箱主今晚整理一批三个月前的老问题(置顶、 公开/私密切换,一次回答都没发生),/stats 顶部「今日回答」就凭空多出几十条。 实测(临时测试,跑完已删):一条三天前回答的提问,依次 SetPinned / SetPrivate / SetPublic 之后,today answers 从 1 变成 2。 改成 `answered_at >= since`(首次回答时间,改写回答不重置): 口径变成「今天首次被回答的提问数」,与页面文案「今日回答 / Answered today」和「回答耗时按首次回答算」都对齐。历史行 answered_at 为空时 `>=` 不成立、自然不计数 —— 那些行本来也不可能「今天被回答」。 性能:answered_at 没有索引,这条 COUNT 是全表过滤;但改动前的 updated_at 同样没有索引,本来就全是 Seq Scan。20 万行实测 旧 36.7ms / 新 38.0ms(差 3.5%,噪声级),执行计划都是 Seq Scan + Aggregate。页面另有 60 秒缓存,不需要为它加索引; 注释里写了真慢了再加 idx_questions_answered_at。 新增 TestTodayAnswersIgnoreNonAnswerWrites(改前红:期望 1 得到 2; 改后绿)。
185 lines
6.5 KiB
Go
185 lines
6.5 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)
|
||
}
|
||
|
||
// TestTodayAnswersIgnoreNonAnswerWrites 「今日回答」只数今天首次被回答的提问。
|
||
//
|
||
// 原来按 updated_at 算,而置顶、公开/私密切换都会刷新 updated_at:
|
||
// 箱主今晚整理一批三个月前的老问题(一次回答都没有),页面上「今日回答」
|
||
// 就凭空多出几十条。改成 answered_at 后,只有真的发生了「首次回答」才算。
|
||
func TestTodayAnswersIgnoreNonAnswerWrites(t *testing.T) {
|
||
conn, clean := newTestDB(t)
|
||
defer clean("questions")
|
||
|
||
store := NewSiteAnalyticsStore(conn)
|
||
questions := NewQuestionsStore(conn)
|
||
ctx := context.Background()
|
||
|
||
now := time.Now()
|
||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||
old := now.Add(-72 * time.Hour)
|
||
|
||
// 三天前提出并回答的历史问题
|
||
legacy, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "旧问题", FromIP: "1.1.1.1"})
|
||
require.NoError(t, err)
|
||
require.NoError(t, questions.AnswerByID(ctx, legacy.ID, "旧答案"))
|
||
// 把三个时间都冻回三天前(AnswerByID 会把 answered_at / updated_at 刷成当下)
|
||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("created_at", old).Error)
|
||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("answered_at", old).Error)
|
||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("updated_at", old).Error)
|
||
|
||
// 今天真的答了一条
|
||
fresh, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "新问题", FromIP: "1.1.1.2"})
|
||
require.NoError(t, err)
|
||
require.NoError(t, questions.AnswerByID(ctx, fresh.ID, "今天的答案"))
|
||
|
||
_, answers, err := store.TodayCounts(ctx, dayStart)
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 1, answers)
|
||
|
||
// 箱主今晚翻旧账:置顶、切私密、再切回公开 —— 一次回答都没发生
|
||
require.NoError(t, questions.SetPinned(ctx, legacy.ID, true))
|
||
require.NoError(t, questions.SetPrivate(ctx, legacy.ID))
|
||
require.NoError(t, questions.SetPublic(ctx, legacy.ID))
|
||
|
||
_, answers, err = store.TodayCounts(ctx, dayStart)
|
||
require.NoError(t, err)
|
||
require.EqualValues(t, 1, answers, "置顶 / 公开切换不该算成「今日回答」")
|
||
}
|
||
|
||
// 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
|
||
}
|