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:
tamakyi
2026-10-24 01:45:34 +08:00
parent 0c282914c2
commit 0226956feb
12 changed files with 399 additions and 24 deletions
+9 -7
View File
@@ -110,11 +110,13 @@ func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
}
day := conv("created_at")
return analyticsDialect{
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
hour: fmt.Sprintf("HOUR(%s)", day),
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, updated_at)",
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
hour: fmt.Sprintf("HOUR(%s)", day),
// 回答耗时以 answered_at 为准。历史行该列为空(加列之前回答的),
// 只能退回 updated_at —— 那部分含已读/置顶带来的误差,无法追溯修正。
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, COALESCE(answered_at, updated_at))",
}
}
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
@@ -124,7 +126,7 @@ func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", loc("created_at")),
dow: fmt.Sprintf("CAST(EXTRACT(DOW FROM %s) AS INTEGER)", loc("created_at")),
hour: fmt.Sprintf("CAST(EXTRACT(HOUR FROM %s) AS INTEGER)", loc("created_at")),
replyMin: "CAST(EXTRACT(EPOCH FROM (updated_at - created_at)) / 60 AS INTEGER)",
replyMin: "CAST(EXTRACT(EPOCH FROM (COALESCE(answered_at, updated_at) - created_at)) / 60 AS INTEGER)",
}
}
@@ -228,7 +230,7 @@ func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics
COUNT(DISTINCT user_id) AS active_boxes,
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
MAX(created_at) AS last_q,
MAX(CASE WHEN answer <> '' THEN updated_at END) AS last_a`).
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
Scan(&row).Error
if err != nil {
return errors.Wrap(err, "aggregate question totals")