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 桩的既有问题)。
This commit is contained in:
@@ -83,6 +83,13 @@ type Question struct {
|
||||
AskerUserID uint `json:"-"`
|
||||
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
|
||||
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
|
||||
// AnsweredAt 首次回答的时间,用于算「回答耗时」。为空表示还没回答过。
|
||||
//
|
||||
// 不能拿 updated_at 当回答时间:GORM 的 Update 会自动刷新 updated_at,
|
||||
// 而已读、置顶、公开/私密切换都走 Update,回答之后再点一次「已读」
|
||||
// 就会把耗时撑大(实测:一条 10 天前的提问被 SetRead 后 updated_at 直接推到当下)。
|
||||
// 只在 answer 由空变非空时写一次,改写回答不重置。
|
||||
AnsweredAt *time.Time `json:"-"`
|
||||
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
|
||||
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
|
||||
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
|
||||
@@ -391,7 +398,7 @@ func (db *questions) AnswerByIDPrivate(ctx context.Context, id uint, answer stri
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"answer": answer, "is_read": true, "is_private": true}).Error; err != nil {
|
||||
Updates(answerUpdates(question.Answer, answer, map[string]interface{}{"is_private": true})).Error; err != nil {
|
||||
return errors.Wrap(err, "update question answer as private")
|
||||
}
|
||||
return nil
|
||||
@@ -407,12 +414,28 @@ func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) err
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"answer": answer, "is_read": true}).Error; err != nil {
|
||||
Updates(answerUpdates(question.Answer, answer, nil)).Error; err != nil {
|
||||
return errors.Wrap(err, "update question answer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// answerUpdates 组装回答写入的字段集合。
|
||||
//
|
||||
// 只有「原本没回答」时才写 answered_at:改写已有回答不该重置首次回答时间,
|
||||
// 否则「回答耗时」会跟着编辑动作变大。extra 用于合并调用方自己的附加字段
|
||||
// (如强制私密),保持仍然只有一条 UPDATE。
|
||||
func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[string]interface{} {
|
||||
updates := map[string]interface{}{"answer": answer, "is_read": true}
|
||||
for k, v := range extra {
|
||||
updates[k] = v
|
||||
}
|
||||
if prevAnswer == "" {
|
||||
updates["answered_at"] = time.Now()
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
|
||||
var question Question
|
||||
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
|
||||
@@ -591,3 +614,14 @@ func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error)
|
||||
LastAnswerAt: t.LastA,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// backfillAnsweredAt 为「加列之前就已经回答」的提问冻结回答时间。
|
||||
//
|
||||
// 只能在刚加上 answered_at 这一列时跑一次(调用方按 HasColumn 判断)。
|
||||
// 取值用 updated_at 当近似 —— 那是当时唯一能拿到的可信时间。
|
||||
// 之后这些行的 updated_at 还会被已读/置顶继续往前推,冻结住才不会让
|
||||
// 历史「回答耗时」一天天变大,看着像统计出错。
|
||||
func backfillAnsweredAt(db *gorm.DB) error {
|
||||
return db.Exec(
|
||||
`UPDATE questions SET answered_at = updated_at WHERE answer <> '' AND answered_at IS NULL`).Error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user