diff --git a/internal/db/answered_at_test.go b/internal/db/answered_at_test.go index bc76278..bcf059d 100644 --- a/internal/db/answered_at_test.go +++ b/internal/db/answered_at_test.go @@ -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) diff --git a/internal/db/questions.go b/internal/db/questions.go index 5d605d1..15c0231 100644 --- a/internal/db/questions.go +++ b/internal/db/questions.go @@ -48,7 +48,6 @@ type QuestionsStore interface { // excludeID 用于跳过正在查看的那条,避免在两条未读之间来回跳。 NextUnread(ctx context.Context, userID, excludeID uint) (*Question, error) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) - GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) // 全站统计 QuestionStats(ctx context.Context) (*QuestionStats, error) @@ -59,7 +58,7 @@ type QuestionStats struct { TotalQuestions int64 AnsweredQuestions int64 LastQuestionAt *time.Time // 最后一个提问发生时间(questions.created_at 最大值) - LastAnswerAt *time.Time // 最后一个回答发生时间(已回答问题的 updated_at 最大值) + LastAnswerAt *time.Time // 最后一个回答发生时间(answered_at 最大值,历史行退回 updated_at) } func NewQuestionsStore(db *gorm.DB) QuestionsStore { @@ -567,27 +566,14 @@ func escapeLike(s string) string { return r.Replace(s) } -// GetHotQuestions 返回"热门问题":最新活跃的公开已回答提问(按回答更新时间倒序)。 -// 提问箱没有点赞数据,用"最近被回答"作为热度近似,适合主页/探索页展示。 -func (db *questions) GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) { - if limit <= 0 { - limit = 10 - } - var questions []*Question - if err := db.WithContext(ctx). - Where("is_private = ? AND answer != ''", false). - Order("updated_at DESC"). - Limit(limit). - Find(&questions).Error; err != nil { - return nil, errors.Wrap(err, "get hot questions") - } - return questions, nil -} - // QuestionStats 返回全站提问相关聚合统计。 // 使用 gorm 查询构造器生成 COUNT / MAX,兼容 MySQL 与 PostgreSQL。 -// 最后一个回答时间取"已回答问题"的 updated_at 最大值——回答动作会更新 updated_at, -// 与 crontab 旧逻辑(answer 非空记录中 updated_at 最大)一致。 +// +// 最后一个回答时间取 answered_at,不取 updated_at:GORM 的 Update 一律刷新 +// updated_at,而置顶、公开/私密切换、标记已读都会走 Update,于是「最后一个回答」 +// 会被这些与回答无关的操作推到当下(实测一条 9 天前回答的提问,置顶后 LastAnswerAt +// 变成"刚刚")。answered_at 为空的是加列之前的历史行,退回 updated_at, +// 与改动前的口径一致,不会让这些行从统计里消失。 func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error) { var total, answered int64 if err := db.WithContext(ctx).Model(&Question{}).Count(&total).Error; err != nil { @@ -602,7 +588,7 @@ func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error) LastA *time.Time `gorm:"column:last_a"` } if err := db.WithContext(ctx).Model(&Question{}). - Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer != '' THEN updated_at ELSE NULL END) AS last_a"). + Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a"). Scan(&t).Error; err != nil { return nil, errors.Wrap(err, "get last question/answer time") }