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(历史行回退)
This commit is contained in:
tamakyi
2026-10-24 10:30:43 +08:00
parent cbb8908275
commit 8380fc9e2b
2 changed files with 61 additions and 22 deletions
+53
View File
@@ -116,6 +116,59 @@ func TestReplyBinsFallBackToUpdatedAt(t *testing.T) {
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)