Files
tamabox/.workbuddy/memory/topics/stats.md
T
tamakyi a3cf99a8da fix(stats): 环比句点明月份与条数,不再写「上月…环比 -75%」
「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份,
再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为
是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。

  改前:上月提问量环比 -75.0%。
  改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。

口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数
写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手
核对百分比(5620/4890 确实是 +14.9%)。

实现:
- `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September"
  ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。
- 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板,
  四种语言里「多了 / 少了」的位置和词形都不一样。
- `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。

测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」,
覆盖上升/下降/持平;`TestMonthText` 锁语言差异。

顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的
monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来,
截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
2026-10-25 09:27:57 +08:00

135 lines
9.1 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%。
**句子必须点明月份与条数**:「9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。」
不要写「上月提问量环比 -75.0%」——两个相对词(上月 / 环比)叠在一起,用户实测读成
「上月同期 vs 本月同期」。也不要用带符号的裸百分比(「环比 -75%」)当结论。
三套句式 `stats.insight.momUp/momDown/momFlat`(方向词进不了同一个模板,语法各语言不通);
参数顺序统一是 本月份名/条数、对比月份名/条数、绝对值百分比,`|pct|<0.05` 走持平。
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「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、不显示单条内容、不做箱主排行(会招骚扰)。
「收到过提问的箱子」这类计数可以,具体是谁不行。
## 文案(读者是访客,不是站长)
- 不写实现细节:缓存策略、懒重算、"服务重启也会重算"、索引、TTL —— 访客要的只是
「多久更新一次」。准确的新鲜度交给页脚「快照时间」那行。
- 不写作者视角的免责:「下面几句…没有推测」「本文」「这里只有」这类,读起来像在跟审稿人
解释,而不是在给访客看。已删 `stats.insight.note`。
- 确实要对访客交代的只有三件:数据来源/隐私(`stats.lead` + `stats.foot.privacy`)、
新鲜度(`stats.foot.cacheNote`)、口径例外(`stats.trend.monthlyNote` 的"当月还没过完")。
- 删说明行会改变间距:`.tb-insights` 自己补 `margin-top`,别指望 `tb-card-note` 的 16px。