Files
tamabox/.workbuddy/memory/topics/stats.md
T
tamakyi 0226956feb fix(stats): 回答耗时改用 answered_at,修测试环境实测出的三处问题
在测试环境(box-dev-api)上逐项验算后发现「回答耗时」这一口径是错的。

1. 回答耗时不能用 updated_at - created_at(P0)
   GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换全走 Update。
   实测:一条 10 天前的提问被 SetRead 标记已读后,updated_at 被推到当下(delta=240h)。
   于是「回答耗时」实际测的是「提问到最后一次被碰」:线上那 6 条「超过 7 天」
   和偏低的 24 小时回复占比都是这么来的。
   修法:新增 questions.answered_at(首次回答时间,可空),只在 answer 由空变非空时
   写一次,改写回答不重置;聚合改用 COALESCE(answered_at, updated_at),
   首次加列时用 updated_at 冻结历史一次(Migrator().HasColumn 判断是否刚加列),
   否则历史值会随着后续的已读/置顶继续变大。

2. 环比句式读不通
   中文「上月提问量比前一个月 -69.6%」、英文「...: -69.6% questions.」都是病句。
   改为「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。

3. 脚注说「当日 0 点的快照」不实
   实际是当天第一次访问时算、服务重启也会重算(实测算出时刻 01:11:50,
   基准日却写 2026-10-24,说明它含了 0 点之后的数据)。改成准确描述。

4. 数据库故障时请求会排成 N×30s
   ovRefresh 串行化 + 30 秒超时意味着数据库挂起时第 N 个请求要等 N×30 秒。
   加 30 秒失败冷却:冷却期内直接降级(有旧快照用旧快照),不再碰数据库。
   todayNumbers 失败也盖时间戳,不再每个请求都打两条 COUNT。

测试:新增 internal/db/answered_at_test.go(5 例:只记一次、私密路径也记、
分桶以 answered_at 为准、历史行回退、回填),并做过变异验证 —— 把口径改回
updated_at 后测试立即失败。internal/stats 补 3 例冷却测试。全量构建与测试通过
(internal/storage 的构建失败是 overlay 桩的既有问题)。
2026-10-24 01:45:34 +08:00

6.5 KiB
Raw Blame History

全站数据页(/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,见下)。
  • 日/周/时分组必须在 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 直接用百分比。
  • 环形:<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、不显示单条内容、不做箱主排行(会招骚扰)。 「收到过提问的箱子」这类计数可以,具体是谁不行。