diff --git a/internal/db/analytics.go b/internal/db/analytics.go index fae5440..62bdc31 100644 --- a/internal/db/analytics.go +++ b/internal/db/analytics.go @@ -188,10 +188,18 @@ func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*Sit return out, nil } -// TodayCounts 取今日提问数与今日回答数,只扫当天数据(走 created_at 索引)。 +// TodayCounts 取今日提问数与今日回答数。 // -// 口径:回答数按「今天被回答过」计,用的是 updated_at —— 置顶等写操作也会刷新它, -// 因此这个数偏大。页面只把它当「今天有多少动静」的量级参考,不做精确承诺。 +// 口径: +// - 今日提问 = created_at 落在今天的提问,走 idx_questions_created_at; +// - 今日回答 = answered_at 落在今天的提问,即「今天首次被回答」。 +// 不能拿 updated_at 当回答时间:置顶、公开/私密切换、标记已读都会刷新它 +// (GORM 的 Update 一律更新 updated_at),箱主晚上整理一批三个月前的老问题 +// —— 一次回答都没有 —— 页面上「今日回答」就会凭空多出几十条。 +// +// answered_at 没有索引,这一条是全表过滤(与改动前的 updated_at 同级别); +// 十万行量级约几十毫秒,页面另有 60 秒缓存,够用。真慢了再加 +// idx_questions_answered_at,不必为这个查询动表结构。 func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int64, int64, error) { var questions, answers int64 if err := db.WithContext(ctx).Model(&Question{}). @@ -199,7 +207,7 @@ func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int6 return 0, 0, errors.Wrap(err, "count today's questions") } if err := db.WithContext(ctx).Model(&Question{}). - Where("answer <> '' AND updated_at >= ?", since).Count(&answers).Error; err != nil { + Where("answer <> '' AND answered_at >= ?", since).Count(&answers).Error; err != nil { return 0, 0, errors.Wrap(err, "count today's answers") } return questions, answers, nil diff --git a/internal/db/analytics_test.go b/internal/db/analytics_test.go index 111f4a0..214450d 100644 --- a/internal/db/analytics_test.go +++ b/internal/db/analytics_test.go @@ -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()