Files
tamabox/internal/db/analytics_test.go
T
tamakyi 219adefc95 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;
改后绿)。
2026-10-24 10:37:36 +08:00

185 lines
6.5 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)
}
// 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
}