Files
tamakyi af39651734 fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」
原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
2026-10-24 22:39:47 +08:00

11 KiB
Raw Permalink Blame History

2026-10-24

/stats 入口收敛到页脚

  • 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。
  • 改动:
    • templates/home.html:删掉统计入口卡整块 + .tb-stats-entry* 样式(原 </details> 后那一段)。
    • route/home.go:Home 不再调 stats.Get(模板已不读 .SiteStats),去掉 logrus import; /api/v1/site-stats 与 SiteStatsAPI 保留未动。
    • 四份 i18n 字典删掉已无引用的 site.stats.open(site.stats 老词条保留)。
    • 页脚 templates/base/footer.html 的 site.footer.stats 位置本就在 rules 与 admin 之间,无需再动。
  • 验证:go build + ./internal/i18n / ./templates / ./route/... 测试全绿;gofmt 无差异; 重新生成预览确认首页无 tb-stats-entry,页脚渲染为「社区规则 | 数据 | 找站长」。
  • 教训:删掉页面唯一消费方后,服务端的取数调用(stats.Get)要同步删, 否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。

/stats 文案与脚注排版修正

  • 页脚入口文案「数据」→「统计数据」(site.footer.stats,四语言 Statistics / 統計データ / 統計數據)。
  • 「提问最集中在三 19 点前后」的 bug:洞察句子复用了热力图列头的 stats.wd.* ("一"…"日" / "Mon"…),那是为窄列头设计的单字标签,进句子就成了"三"、英文是 "Wed"。 新增一组完整名 stats.weekday.1..7(周一/週一/Monday/月曜日),洞察改用这组; stats.wd.* 只留给热力图列头。已在 overview_test.go 加断言锁住,防止回退。 → 教训:短标签和完整名必须分两套词条,别为了少写一组 key 复用。
  • 页面脚注四条时间(基准日 / 计算于 / 最后一条提问 / 最后一条回答)由 「A:x · B:y」挤成一行改为 grid-template-columns: max-content 1fr 各占一行, 标签列与值列对齐;下面两段说明仍为独立段落。四个标签统一带冒号。

部署前自查:两个 P0 都已修

用户部署到测试环境后要求自查。查出的问题都是我自己上一轮引入的:

  1. 「今日」两项被每日缓存冻住(P0,与页面「实时」说明矛盾)。 → TodayCounts 拆出,60 秒 TTL + 跨日按日期失效;命中缓存时返回副本。 新增 TestTodayNumbersNotFrozen / TestTodayCacheExpiresAcrossMidnight。
  2. 日期分组跟着 PG 会话时区(默认 UTC)(P0,隐蔽:数字都"合理",只是整体偏 8 小时)。 psql 实测:2026-10-24 01:30+08 在 UTC 会话下 to_char 得 2026-10-23、hour=17。 → analyticsLocalize() 用 AT TIME ZONE INTERVAL '+08:00'(偏移取 time.At() 的 Zone, 不用 IANA 名)。EXPLAIN 确认过滤仍走 created_at 索引。
  3. PeakDay 用 >= 会让「最忙的一天」随更新漂移到最近的同值日期 → 改 >。
  4. ovRefresh 里的聚合改用 context.WithoutCancel + 30s 超时:第一个访客中途关页面时, 排队的请求不会各自重跑全表 GROUP BY。

性能实测(20 万行):totals 337ms / 字数分布 313ms / 热力图 100ms / 日趋势 47ms, 本地合计约 0.8s,1C1G 估 2s,靠预热兜住。百万行才需要合并扫描。

顺带发现(未改,与本次无关):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。

30 天折线补纵轴刻度(cbb8908)

  • internal/template/chart.go:Polyline/AreaPath 两个函数收敛成 NewLineChart 返回 LineChart{HasData,Max,Points,Area,Ticks}。坐标/面积/刻度必须一次算, 分两次各自归一化会让线和刻度错开半格。
  • 纵轴上限 niceMax(峰值)(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。 1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。
  • 刻度文字不能放进 SVG:viewBox 是 preserveAspectRatio="none" 横向拉伸的, 文字会被拉扁。改左边一列 HTML 标签(.tb-line-y + 绝对定位 .tb-line-tick, top 用 Go 算好的 TopPct),横向虚线仍在 SVG 里(non-scaling-stroke)。
  • 列宽靠看不见的 .tb-line-ruler(最宽那条刻度)在流内占位撑开,不写死 em。
  • 面积路径改为沿基线 h-padBottom 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。
  • 全零/空数据只出轴不出线(HasData),否则一条贴基线的直线看着像"数据都是 0"。
  • internal/template/chart_test.go:锁三条不变式 + 0..2000 穷举。
  • 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。

取证:GetHotQuestions 的 ORDER BY updated_at DESC(未改代码)

  • 先纠正前提:这个方法全仓零调用点,只有接口声明 + 实现(route/ 里 Questions 的 15 个调用方法不含它;git log -S 自 baseline 起也没删过调用)。属死代码,线上零影响。
  • 连真库实测(临时测试,跑完已删):
    • 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → GetHotQuestions 得 new old;
    • SetRead(old) 后 → old new(名次被翻);SetPinned(old) 同样;
    • 换 ORDER BY COALESCE(answered_at, updated_at) DESC → 稳定 new old;
    • old 的 answered_at / updated_at 相差 216h。
  • 机制:dbutil.Model.UpdatedAt 是 GORM 约定字段,任何 Update/Updates/Save 都会刷新。 所以污染源不止「标记已读」:SetPinned / SetPublic / SetPrivate / UpdateCensor 全是。
  • 同类残留(仍在线上生效):QuestionStats 的 LastAnswerAt = MAX(updated_at), 被老的 /api/v1/site-stats 用着(首页已不 fetch,但公开接口还在)。实测被推到当下, 比真实最后回答晚 1 小时。新 /stats 页已改用 COALESCE(answered_at, updated_at)。
  • 另:analytics.go 的「今日回答数」也用 updated_at >= since(注释已承认偏大),未改。

已改(8380fc9):删死代码 + 修 LastAnswerAt

  • 删 GetHotQuestions(接口声明 + 实现)。零调用点、唯一实现者 *questions、无 fake/mock。
  • QuestionStats.last_a 改 MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))。 先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。
  • 影响面只有 /api/v1/site-stats(last_answer_at + text),字段名/结构不变,值变准。
  • 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行); 执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。
  • 仍未改:analytics.go:202 今日回答数用 updated_at >= since(置顶会算进"今日回答"), 改它会直接改变 /stats 首页数字,留待用户决定。

已改(219adef):今日回答数也换 answered_at

  • TodayCounts 今日回答:answer <> '' AND updated_at >= ? → answered_at >= ?。 实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。
  • 口径 = 「今天首次被回答的提问数」,与文案「今日回答 / Answered today」一致; 改写回答不重置 answered_at,所以改写不算今日回答(有意)。
  • 性能:answered_at 无索引 → Seq Scan;但 updated_at 本来也没索引,同样是 Seq Scan。 20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。没加索引,注释里留了 「真慢了再加 idx_questions_answered_at」。
  • ⚠️ 顺手发现:analytics.go 注释原写「只扫当天数据(走 created_at 索引)」, 但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。
  • 测试:TestTodayAnswersIgnoreNonAnswerWrites(改前红 1 vs 2,改后绿)。

已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」

  • 用户反馈「计算于」表达怪:它其实只是累计数字与图表那份快照的生成时间, 而今日两项在缓存命中时会被 withToday 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。
  • 「数据基准日」与它 100% 重复:DataDate 就是 UpdatedAt 的日期部分(同一个 now), 删掉 stats.foot.dataDate 词条 + Overview.DataDate 字段(模板/预览测试同步改)。
  • 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面 的 stats.foot.cacheNote 说明,不塞进标签里。
  • 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(stats.foot.updated 改名改值)。
  • i18n 有词条数量对等测试,删键必须四个语言一起删。