「上月提问量环比 -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%」这种假证据,改成从数据算。
135 lines
9.1 KiB
Markdown
135 lines
9.1 KiB
Markdown
# 全站数据页(/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。
|