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
+28
View File
@@ -47,3 +47,31 @@
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
## 测试环境实测(box-dev-api.337845818.best,用户时区 UTC+8)
- `/stats` 200,首访 5.2s(含 TLS),稳定后 TTFB 1.6s,同站 `/rules` 1.34s
→ 本页只多约 250ms,1C1G 上可接受,无需合并聚合 SQL。
- 环境默认语言是 ja(`/`、`/rules` 同样是 ja),与本次改动无关;语言存 session
(`/i18n/{lang}`),故 /stats **绝不能加 CDN 缓存头** —— 内容随 session 变化且无 Vary: Cookie。
- 逐项验算自洽:月柱合计 95 = 累计提问;热力图 7×24=168 格合计 95;
最忙时段周三 19 点 = 22 与小时合计 26 峰值一致;折线 30 点峰值 10-19 共 5 条。
- `/stats/` 尾斜杠 302 跳首页 —— 与 `/rules/`、`/sponsor/` 一致,框架行为。
## P0:回答耗时口径是错的(本次修)
`SetRead` 把 updated_at 往前推(实测 10 天前的提问,delta = 240h)。
所以「回答耗时 = updated_at − created_at」实际测的是「提问到最后一次被碰」,
线上「6 条超过 7 天」就是这么来的,24 小时占比 66.7% 同样偏低。
修法:`questions.answered_at`(首次回答时间),只在 answer 由空变非空时写,
改写回答不重置;聚合 `COALESCE(answered_at, updated_at)`;加列时回填一次冻结历史。
测试:`internal/db/answered_at_test.go` 5 个用例,含变异验证(改回 updated_at 即红)。
## 顺带修
- 环比句式:中文「比前一个月 -69.6%」、英文「...: -69.6% questions.」读不通 →
「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
+9 -3
View File
@@ -30,10 +30,16 @@ db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
- 回答耗时用 `updated_at - created_at`,但**置顶等写操作也会刷新 updated_at**,
所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。
- 回答耗时用 `answered_at - created_at`(**不是 updated_at**)。GORM 的 `Update`
会自动刷新 updated_at,已读/置顶/公开切换都走 Update —— 实测一条 10 天前的提问
被 `SetRead` 后 updated_at 直接推到当下。故新增 `questions.answered_at`(可空),
只在 answer 由空变非空时写一次,改写回答不重置;历史行为 NULL 时
`COALESCE(answered_at, updated_at)` 回退,且首次加列时用 updated_at 冻结一次
(`backfillAnsweredAt`,靠 `Migrator().HasColumn` 判断是否刚加列)。
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
- 累计与图表是当日 0 点快照,「今日提问/回答」两项实时(60 秒 TTL,见下)。
句式用「上月提问量环比 %s%%」(带符号),不要写「比前一个月 -69.6%」。
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
「今日提问/回答」两项实时(60 秒 TTL,见下)。
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
不能依赖数据库会话时区 —— 见「时区」一节。