Files
tamabox/.workbuddy/memory/2026-10-24.md
T
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

145 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 有词条数量对等测试,删键必须四个语言一起删。