Files
tamabox/internal/db/answered_at_test.go
T
tamakyi 8380fc9e2b fix(db): 「最后一个回答」不再被置顶/公开切换推到现在
`QuestionStats` 的 `last_a` 取的是 `MAX(updated_at)`,而 GORM 的 Update
一律刷新 updated_at:置顶、公开/私密切换、标记已读都会走 Update。
后果是箱主整理一下旧问题(一次回答都没发生),公开接口
`/api/v1/site-stats` 就说「刚刚有人回答了」。

实测(真库,临时测试已删):一条 9 天前回答的提问,置顶/公开切换后
LastAnswerAt 从 `10-15 10:23` 变成 `10-24 10:23`,偏差 216 小时。

改成 `COALESCE(answered_at, updated_at)`:
- 新数据取 answered_at(首次回答时间,改写回答不重置);
- 加列之前的历史行 answered_at 为空,退回 updated_at —— 与改动前口径
  一致,不会让这些行从统计里消失(有测试锁住这一半)。

同时删掉 `GetHotQuestions`:全仓零调用点(只有接口声明 + 实现,route/
里 Questions 的 15 个调用不含它),且同样按 updated_at 排序 ——
死代码 + 错误排序,留着就是给以后的人埋坑,要用时按 answered_at 重写。

影响面:`/api/v1/site-stats` 的 `last_answer_at` 字段与 `text` 里的
「最后一个回答发生在」,字段名与结构不变,只是值更准(更早)。无 schema
变更、无接口变更。性能:执行计划仍是 Seq Scan + Aggregate,5 万行实测
7.9ms vs 8.0ms,差异在噪声带内(0~1.8ms,折合每行约 30ns),
而该接口有 30 分钟进程内缓存。

新增两个回归测试:
- TestQuestionStatsLastAnswerIgnoresLaterEdits(改前红、改后绿)
- TestQuestionStatsLastAnswerFallsBackToUpdatedAt(历史行回退)
2026-10-24 10:30:43 +08:00

203 lines
8.6 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"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestAnsweredAtOnlySetOnFirstAnswer 回答时间只记一次。
//
// 背景:GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换
// 都走 Update。若拿 updated_at 当回答时间,回答完再点一次「已读」,
// 耗时就会从几分钟变成几十天 —— 线上那份「6 条超过 7 天」的数据就是这么来的。
func TestAnsweredAtOnlySetOnFirstAnswer(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
q := Question{UserID: 1, Content: "hello", CreatedAt: createdAt, UpdatedAt: createdAt}
require.NoError(t, conn.Create(&q).Error)
store := NewQuestionsStore(conn)
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "first"))
var got Question
require.NoError(t, conn.First(&got, q.ID).Error)
require.NotNil(t, got.AnsweredAt, "首次回答必须记下 answered_at")
firstAt := *got.AnsweredAt
require.WithinDuration(t, time.Now(), firstAt, time.Minute)
// 改写回答:answered_at 不能被重置,否则「回答耗时」会跟着编辑动作变大。
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "edited"))
require.NoError(t, conn.First(&got, q.ID).Error)
require.NotNil(t, got.AnsweredAt)
require.Equal(t, firstAt.Unix(), got.AnsweredAt.Unix(), "改写回答不该重置 answered_at")
}
// TestAnsweredAtSetWhenAnsweringAsPrivate 强制私密那条 UPDATE 同样要记时间。
func TestAnsweredAtSetWhenAnsweringAsPrivate(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
q := Question{UserID: 1, Content: "secret"}
require.NoError(t, conn.Create(&q).Error)
require.NoError(t, NewQuestionsStore(conn).AnswerByIDPrivate(context.Background(), q.ID, "answer"))
var got Question
require.NoError(t, conn.First(&got, q.ID).Error)
require.NotNil(t, got.AnsweredAt)
require.True(t, got.IsPrivate)
require.True(t, got.IsRead)
}
// TestReplyBinsUseAnsweredAt 回答耗时以 answered_at 为准,不受后来的「已读」影响。
//
// 构造:72 小时前提问、10 分钟前回答(落第一档);但 updated_at 被改成当下
// (相当于回答完又被读了一次),差值是 72 小时 = 4320 分钟,正好落到第四档。
// 若仍在用 updated_at,这条会进第四档而不是第一档。
func TestReplyBinsUseAnsweredAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
now := time.Now()
createdAt := now.Add(-72 * time.Hour)
answeredAt := createdAt.Add(10 * time.Minute) // 提问后 10 分钟回答 → 第一档
q := Question{UserID: 1, Content: "hi", CreatedAt: createdAt, UpdatedAt: createdAt}
require.NoError(t, conn.Create(&q).Error)
// 直接写列,绕开 GORM 的 UpdatedAt 自动更新。
require.NoError(t, conn.Exec(
"UPDATE questions SET answer = ?, is_read = ?, answered_at = ?, updated_at = ? WHERE id = ?",
"answer", true, answeredAt, now, q.ID).Error)
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
require.NoError(t, err)
var total int64
for _, n := range got.ReplyBins {
total += n
}
require.EqualValues(t, 1, total)
require.EqualValues(t, 1, got.ReplyBins[0], "10 分钟的回答应落在第一档")
require.EqualValues(t, 0, got.ReplyBins[4], "不该被 pushed 到当下的 updated_at 带偏")
// 「最后一条回答」同理:取 answered_at 而不是被刷新过的 updated_at。
require.NotNil(t, got.LastAnswerAt)
require.WithinDuration(t, answeredAt, *got.LastAnswerAt, time.Minute)
}
// TestReplyBinsFallBackToUpdatedAt 加列之前回答的历史行 answered_at 为空,
// 退回 updated_at,不能让这些提问从分布里消失。
func TestReplyBinsFallBackToUpdatedAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
now := time.Now()
// 10 天前提问、9 天前处理:差值 24 小时(1440 分钟),落在第三档。
createdAt := now.Add(-10 * 24 * time.Hour)
updatedAt := now.Add(-9 * 24 * time.Hour)
q := Question{UserID: 1, Content: "old", CreatedAt: createdAt, UpdatedAt: createdAt}
require.NoError(t, conn.Create(&q).Error)
require.NoError(t, conn.Exec(
"UPDATE questions SET answer = ?, answered_at = NULL, updated_at = ? WHERE id = ?",
"old answer", updatedAt, q.ID).Error)
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
require.NoError(t, err)
require.EqualValues(t, 1, got.Answered)
require.EqualValues(t, 1, got.ReplyBins[3], "历史行应退回 updated_at 计入分布")
}
// TestQuestionStatsLastAnswerIgnoresLaterEdits 「最后一个回答」不能跟着置顶/公开切换跑。
//
// QuestionStats 仍被公开接口 /api/v1/site-stats 使用。它原来取 MAX(updated_at),
// 而 updated_at 会被 SetPinned / SetPrivate / SetPublic / SetRead 刷新,
// 于是箱主整理一下旧问题(哪怕一次回答都没发生),接口就说「刚刚有人回答了」。
func TestQuestionStatsLastAnswerIgnoresLaterEdits(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
store := NewQuestionsStore(conn)
ctx := context.Background()
now := time.Now().Truncate(time.Second)
answeredAt := now.Add(-9 * 24 * time.Hour)
q := Question{UserID: 1, Content: "q", CreatedAt: answeredAt.Add(-time.Hour), UpdatedAt: answeredAt}
require.NoError(t, conn.Create(&q).Error)
require.NoError(t, store.AnswerByID(ctx, q.ID, "answer"))
// AnswerByID 会把两个时间刷成当下,冻回真实回答时刻(模拟一条正常的历史数据)。
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
UpdateColumn("updated_at", answeredAt).Error)
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
UpdateColumn("answered_at", answeredAt).Error)
// 全是「没回答任何东西」的写操作。
require.NoError(t, store.SetPinned(ctx, q.ID, true))
require.NoError(t, store.SetPrivate(ctx, q.ID))
require.NoError(t, store.SetPublic(ctx, q.ID))
require.NoError(t, store.SetRead(ctx, q.ID))
qs, err := store.QuestionStats(ctx)
require.NoError(t, err)
require.NotNil(t, qs.LastAnswerAt)
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Minute,
"最后一个回答被置顶/公开切换刷新过的 updated_at 带偏了")
}
// TestQuestionStatsLastAnswerFallsBackToUpdatedAt 加列之前的历史行 answered_at 为空,
// 退回 updated_at —— 与改动前的行为一致,不能让这些行从统计里消失。
func TestQuestionStatsLastAnswerFallsBackToUpdatedAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
answeredAt := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)
require.NoError(t, conn.Exec(
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'legacy','a',false,true,false,?,?)",
answeredAt.Add(-time.Hour), answeredAt).Error)
qs, err := NewQuestionsStore(conn).QuestionStats(context.Background())
require.NoError(t, err)
require.NotNil(t, qs.LastAnswerAt)
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Second)
}
// TestBackfillAnsweredAt 历史行回填:拿 updated_at 冻结一次,之后不再随已读漂移。
func TestBackfillAnsweredAt(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("questions")
// 列由 AutoMigrate 建好,HasColumn 必须能认出来(启动时靠它判断「是不是刚加列」)。
require.True(t, conn.Migrator().HasColumn(&Question{}, "answered_at"))
now := time.Now()
createdAt := now.Add(-20 * 24 * time.Hour)
answeredAtOld := now.Add(-19 * 24 * time.Hour)
// 一条历史已回答(answered_at 为空),一条未回答(不该被回填)。
require.NoError(t, conn.Exec(
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'old','a',false,true,false,?,?)",
createdAt, answeredAtOld).Error)
require.NoError(t, conn.Exec(
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'open','',false,false,false,?,?)",
createdAt, answeredAtOld).Error)
require.NoError(t, backfillAnsweredAt(conn))
var answered Question
require.NoError(t, conn.Where("content = ?", "old").First(&answered).Error)
require.NotNil(t, answered.AnsweredAt)
require.WithinDuration(t, answeredAtOld, *answered.AnsweredAt, time.Second)
var open Question
require.NoError(t, conn.Where("content = ?", "open").First(&open).Error)
require.Nil(t, open.AnsweredAt, "未回答的提问不该被回填")
}