feat(stats): 新增公开数据页 /stats,入口收敛到页脚

首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
This commit is contained in:
tamakyi
2026-10-24 00:57:08 +08:00
parent c680671b7e
commit a5441c99fd
25 changed files with 2280 additions and 80 deletions
+27
View File
@@ -221,3 +221,30 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。
### 手机端时间的年份:逐条补年,不做按年分组(DateCompact)
- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`,
跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。
- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切
排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序
(最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。
真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。
- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px,
差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。
## 全站数据页 /stats(替换首页统计卡)
- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。
明细见 `topics/stats.md`。
- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go`
(自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、
`route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。
- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray,
热力图 CSS grid + 主色 opacity 分 4 档。
- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。
- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。
- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、
`internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。
- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 →
令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。
+15
View File
@@ -0,0 +1,15 @@
# 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 词条也要一起清,避免留死条目。
+1 -1
View File
@@ -3,7 +3,7 @@
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
`honeypot` 蜜罐多家族伪装 / `misc` 其它约定。
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。
## 协作
+66
View File
@@ -0,0 +1,66 @@
# 全站数据页(/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`;登录 = 总数 − 匿名。
- 回答耗时用 `updated_at - created_at`,但**置顶等写操作也会刷新 updated_at**,
所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
- 累计与图表是当日 0 点快照,「今日提问/回答」两项实时。页面底部已写明。
## 图表实现(零前端库)
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
- 折线:`Polyline` / `AreaPath` 模板函数算坐标,SVG `viewBox` + `preserveAspectRatio="none"`
+ `vector-effect: non-scaling-stroke`(否则窄屏描边被压细)。
- 柱状 / 分布条:`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、不显示单条内容、不做箱主排行(会招骚扰)。
「收到过提问的箱子」这类计数可以,具体是谁不行。