原来四行时间里,前两行说的是同一件事: 「数据基准日: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 测试全绿。
7.9 KiB
全站数据页(/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}, SVGviewBox+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)与 Gotime.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、不显示单条内容、不做箱主排行(会招骚扰)。 「收到过提问的箱子」这类计数可以,具体是谁不行。