`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(历史行回退)
203 lines
8.6 KiB
Go
203 lines
8.6 KiB
Go
// 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, "未回答的提问不该被回填")
|
||
}
|