# 全站数据页(/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 点快照,「今日提问/回答」两项实时(60 秒 TTL,见下)。 - 日/周/时分组必须在 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 图表库。 - 折线:`Polyline` / `AreaPath` 模板函数算坐标,SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`(否则窄屏描边被压细)。 - 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。 - 环形:`` + `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、不显示单条内容、不做箱主排行(会招骚扰)。 「收到过提问的箱子」这类计数可以,具体是谁不行。