Files
tamabox/.workbuddy/memory/topics/stats.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

121 lines
7.9 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.
# 全站数据页(/stats)
2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。
入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格**
(`templates/base/footer.html`,词条 `site.footer.stats`)。
首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放),
`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。
新增入口不要再往首页加——那会让两处数字需要同步维护。
## 分层
```
db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
→ stats.OverviewData(ctx) // 按自然日缓存(每天只算一次)
→ stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察
→ route.Stats → templates/stats.html
```
- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是
`COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、
`EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。
- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效,
当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。
- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里
`printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。
- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。
## 口径(改之前先看)
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
- 回答耗时用 `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%。
句式用「上月提问量环比 %s%%」(带符号),不要写「比前一个月 -69.6%」。
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
「今日提问/回答」两项实时(60 秒 TTL,见下)。
- 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表,
今日两项会被 `withToday` 换掉。原先另有「数据基准日」一行,与它是 100% 重复
(`Overview.DataDate` 就是 `UpdatedAt` 的日期部分),已删。
- 今日两项的 60s TTL **不要**为了省查询改成 30 分钟:每分钟最多 2 条 COUNT,
20 万行实测 38ms/条 → 最坏 109 秒 DB 时间/天(0.13%),换不到什么;
而 30 分钟会让「(实时)」变成假话、提问者自己看不见刚发的那条。
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
不能依赖数据库会话时区 —— 见「时区」一节。
## 两个已踩过的坑(2026-10-24 部署前自查发现)
**1. 「今日」两项曾被每日缓存冻住。** 原先 `SiteAnalytics` 里查了今日数,塞进
`Overview`,而 `Overview` 整天复用 → 页面顶着「实时」的说明显示一整天不变的常量。
现在拆出 `SiteAnalyticsStore.TodayCounts`,`stats` 层单独 60 秒缓存(`todayTTL`),
跨日按日期字符串失效(`todayDate`)。缓存命中时返回的是**副本**(`withToday`),
不能原地改 `ovCached` —— 那是数据竞争。
**2. 日期分组默认跟着 PG 会话时区走,通常是 UTC。** PG 官方镜像 `timezone=UTC`,
而 Go 按容器本地时区(Dockerfile 设 Asia/Shanghai)切「今天」。实测同一条
`2026-10-24 01:30+08` 的提问:会话 UTC 下 `to_char` 给 `2026-10-23`、`EXTRACT(HOUR)` 给 17。
→ 日报表整体偏 8 小时(中国 0–8 点被算进前一天),热力图时段全错位,且与「今日」对不上。
现在 `analyticsLocalize()` 统一包一层 `AT TIME ZONE INTERVAL '+08:00'`,偏移取自
`time.Time.Zone()`(不用 IANA 名:Go 在未设 TZ 时给 "Local",PG 不认)。
过滤条件仍是裸列,索引照用(EXPLAIN 确认走 Bitmap Index Scan)。
## 性能实测(20 万行 questions,本地 PG,冷缓存)
| 查询 | 耗时 | 扫描方式 |
|---|---|---|
| totals(全表条件聚合) | 337 ms | Seq Scan |
| 字数分布(全表) | 313 ms | Parallel Seq Scan |
| 热力图 180 天 | 100 ms | Bitmap Index Scan |
| 日趋势 30 天 | 47 ms | Bitmap Index Scan |
合计约 0.8 秒;1C1G 云机按 2–3 倍算约 2 秒。靠启动后 3 秒预热挪出请求路径,
可接受。**若 questions 到百万行**,三次全表扫描会到 6–10 秒,那时应把
totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的无条件 CASE 聚合,
合并后只扫一次)。现在不做:不知道真实表规模,每天只算一次,不值得为省 0.5 秒
把 SQL 拆成大块。
## 图表实现(零前端库)
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
- 折线:`NewLineChart` 一个函数给出 `{Max, Points, Area, Ticks}`,
SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`
(否则窄屏描边被压细)。
- **坐标 / 面积 / 刻度必须同一次调用算出**:分开算会各按自己看到的峰值归一化,
线和刻度就错开半格。
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n),刻度 = 上限 / 一半 / 0;
上限恒为偶数 → 中间刻度必是整数。1.5 倍只在 mag≥100 时参与(否则是 7.5 半格)。
- 刻度文字放 SVG **外面**:viewBox 横向被拉伸,里面的文字会跟着变扁。
做法是左列绝对定位的 HTML 标签(top 用 Go 算好的 `TopPct`)+ 看不见的占位元素撑列宽。
- 面积沿基线(`h-padBottom`)闭合,别落到 viewBox 底边(那是轴标签的留白)。
- 全零/空数据只出轴不出线,否则贴基线的直线看着像「数据都是 0」。
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
不用算周长。
- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档
(连续透明度在小方块上肉眼分不出来)。
- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0;
展示顺序(周一起)在 `weekdayOrder` 里转。
## 预览与验证
- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出
`outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。
- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://,
`:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、
各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485,
截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。
## 隐私红线
公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。
「收到过提问的箱子」这类计数可以,具体是谁不行。