fix(stats): 「今日回答」改按 answered_at 计,不再把置顶算成回答

`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;
改后绿)。
This commit is contained in:
tamakyi
2026-10-24 10:37:36 +08:00
parent 8380fc9e2b
commit 219adefc95
2 changed files with 57 additions and 4 deletions
+45
View File
@@ -102,6 +102,51 @@ func TestSiteAnalytics(t *testing.T) {
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()