Files
tamabox/internal/db/answered_at_test.go
T
tamakyi 0226956feb fix(stats): 回答耗时改用 answered_at,修测试环境实测出的三处问题
在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。

1. 回答耗时不能用 updated_at - created_at(P0)
   GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。
   实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。
   于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」
   和偏低的 24 小时回复占比都是这么来的。
   修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时
   写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at),
   首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列),
   否则历史值会随着后续的已读/置顶继续变大。

2. 环比句式读不通
   中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。
   改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。

3. 脚注说「当日 0 点的快照」不实
   实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50,
   基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。

4. 数据库故障时请求会排成 N×30s
   ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。
   加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。
   todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。

测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、
分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回
updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过
(internal/storage 的构建失败是 overlay 桩的既有问题)。
2026-10-24 01:45:34 +08:00

150 lines
6.1 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 计入分布")
}
// 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, "未回答的提问不该被回填")
}