From af396517340c3ce8ae01c9db0f89bc02e915d825 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 24 Oct 2026 22:39:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(stats):=20=E9=A1=B5=E8=84=9A=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=A0=87=E7=AD=BE=E6=94=B9=E6=88=90=E3=80=8C=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E6=97=B6=E9=97=B4=E3=80=8D=EF=BC=8C=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=E3=80=8C=E6=95=B0=E6=8D=AE=E5=9F=BA?= =?UTF-8?q?=E5=87=86=E6=97=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来四行时间里,前两行说的是同一件事: 「数据基准日: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 测试全绿。 --- .workbuddy/memory/2026-10-24.md | 67 +++++++++++++++++++++++++++++++ .workbuddy/memory/topics/stats.md | 19 ++++++++- internal/i18n/dict_en.go | 3 +- internal/i18n/dict_ja.go | 3 +- internal/i18n/dict_zh_cn.go | 3 +- internal/i18n/dict_zh_tw.go | 3 +- internal/stats/overview.go | 5 ++- templates/stats.html | 2 - templates/zz_preview_test.go | 1 - 9 files changed, 91 insertions(+), 15 deletions(-) diff --git a/.workbuddy/memory/2026-10-24.md b/.workbuddy/memory/2026-10-24.md index 2d3a78d..eac458d 100644 --- a/.workbuddy/memory/2026-10-24.md +++ b/.workbuddy/memory/2026-10-24.md @@ -75,3 +75,70 @@ - 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。 - DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却 (`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。 + +## 30 天折线补纵轴刻度(`cbb8908`) + +- `internal/template/chart.go`:`Polyline`/`AreaPath` 两个函数收敛成 `NewLineChart` + 返回 `LineChart{HasData,Max,Points,Area,Ticks}`。**坐标/面积/刻度必须一次算**, + 分两次各自归一化会让线和刻度错开半格。 +- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。 + 1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。 +- 刻度文字**不能放进 SVG**:viewBox 是 `preserveAspectRatio="none"` 横向拉伸的, + 文字会被拉扁。改左边一列 HTML 标签(`.tb-line-y` + 绝对定位 `.tb-line-tick`, + top 用 Go 算好的 `TopPct`),横向虚线仍在 SVG 里(`non-scaling-stroke`)。 +- 列宽靠看不见的 `.tb-line-ruler`(最宽那条刻度)在流内占位撑开,不写死 em。 +- 面积路径改为沿基线 `h-padBottom` 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。 +- 全零/空数据只出轴不出线(`HasData`),否则一条贴基线的直线看着像"数据都是 0"。 +- `internal/template/chart_test.go`:锁三条不变式 + 0..2000 穷举。 +- 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。 + +## 取证:`GetHotQuestions` 的 `ORDER BY updated_at DESC`(未改代码) + +- **先纠正前提:这个方法全仓零调用点**,只有接口声明 + 实现(`route/` 里 Questions 的 + 15 个调用方法不含它;`git log -S` 自 baseline 起也没删过调用)。属死代码,线上零影响。 +- 连真库实测(临时测试,跑完已删): + - 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → `GetHotQuestions` 得 `new old`; + - `SetRead(old)` 后 → `old new`(名次被翻);`SetPinned(old)` 同样; + - 换 `ORDER BY COALESCE(answered_at, updated_at) DESC` → 稳定 `new old`; + - old 的 `answered_at` / `updated_at` 相差 216h。 +- 机制:`dbutil.Model.UpdatedAt` 是 GORM 约定字段,任何 `Update/Updates/Save` 都会刷新。 + 所以污染源不止「标记已读」:`SetPinned` / `SetPublic` / `SetPrivate` / `UpdateCensor` 全是。 +- **同类残留(仍在线上生效)**:`QuestionStats` 的 `LastAnswerAt = MAX(updated_at)`, + 被老的 `/api/v1/site-stats` 用着(首页已不 fetch,但公开接口还在)。实测被推到当下, + 比真实最后回答晚 1 小时。新 `/stats` 页已改用 `COALESCE(answered_at, updated_at)`。 +- 另:`analytics.go` 的「今日回答数」也用 `updated_at >= since`(注释已承认偏大),未改。 + +## 已改(`8380fc9`):删死代码 + 修 LastAnswerAt + +- 删 `GetHotQuestions`(接口声明 + 实现)。零调用点、唯一实现者 `*questions`、无 fake/mock。 +- `QuestionStats.last_a` 改 `MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))`。 + 先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。 +- 影响面只有 `/api/v1/site-stats`(`last_answer_at` + `text`),字段名/结构不变,值变准。 +- 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行); + 执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。 +- 仍未改:`analytics.go:202` 今日回答数用 `updated_at >= since`(置顶会算进"今日回答"), + 改它会直接改变 /stats 首页数字,留待用户决定。 + +## 已改(`219adef`):今日回答数也换 answered_at + +- `TodayCounts` 今日回答:`answer <> '' AND updated_at >= ?` → `answered_at >= ?`。 + 实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。 +- 口径 = 「今天**首次**被回答的提问数」,与文案「今日回答 / Answered today」一致; + 改写回答不重置 answered_at,所以改写不算今日回答(有意)。 +- 性能:`answered_at` 无索引 → Seq Scan;但 `updated_at` 本来也没索引,同样是 Seq Scan。 + 20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。**没加索引**,注释里留了 + 「真慢了再加 idx_questions_answered_at」。 +- ⚠️ 顺手发现:`analytics.go` 注释原写「只扫当天数据(走 created_at 索引)」, + 但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。 +- 测试:`TestTodayAnswersIgnoreNonAnswerWrites`(改前红 1 vs 2,改后绿)。 + +## 已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」 + +- 用户反馈「计算于」表达怪:它其实只是**累计数字与图表**那份快照的生成时间, + 而今日两项在缓存命中时会被 `withToday` 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。 +- 「数据基准日」与它 100% 重复:`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 `now`), + 删掉 `stats.foot.dataDate` 词条 + `Overview.DataDate` 字段(模板/预览测试同步改)。 +- 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面 + 的 `stats.foot.cacheNote` 说明,不塞进标签里。 +- 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(`stats.foot.updated` 改名改值)。 +- i18n 有词条数量对等测试,删键必须四个语言一起删。 diff --git a/.workbuddy/memory/topics/stats.md b/.workbuddy/memory/topics/stats.md index df45912..65db92b 100644 --- a/.workbuddy/memory/topics/stats.md +++ b/.workbuddy/memory/topics/stats.md @@ -40,6 +40,12 @@ db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存 句式用「上月提问量环比 %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'` 显式换算, 不能依赖数据库会话时区 —— 见「时区」一节。 @@ -78,8 +84,17 @@ totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的 站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。 -- 折线:`Polyline` / `AreaPath` 模板函数算坐标,SVG `viewBox` + `preserveAspectRatio="none"` - + `vector-effect: non-scaling-stroke`(否则窄屏描边被压细)。 +- 折线:`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 直接用百分比。 - 环形:`` + `rotate(-90)`, 不用算周长。 diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 4bbb24f..f936946 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -1029,8 +1029,7 @@ var enDict = map[string]string{ "stats.weekday.6": "Saturday", "stats.weekday.7": "Sunday", - "stats.foot.dataDate": "Data as of", - "stats.foot.updated": "computed at", + "stats.foot.updated": "Snapshot:", "stats.foot.prevMonth": "Last question:", "stats.foot.prevAnswer": "Last answer:", "stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index dac88f4..eca7f1a 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -1028,8 +1028,7 @@ var jaDict = map[string]string{ "stats.weekday.6": "土曜日", "stats.weekday.7": "日曜日", - "stats.foot.dataDate": "集計基準日:", - "stats.foot.updated": "算出時刻:", + "stats.foot.updated": "集計時刻:", "stats.foot.prevMonth": "最後の質問:", "stats.foot.prevAnswer": "最後の回答:", "stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 6948dda..cea5124 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -1029,8 +1029,7 @@ var zhCNDict = map[string]string{ "stats.weekday.6": "周六", "stats.weekday.7": "周日", - "stats.foot.dataDate": "数据基准日:", - "stats.foot.updated": "计算于:", + "stats.foot.updated": "快照时间:", "stats.foot.prevMonth": "最后一条提问:", "stats.foot.prevAnswer": "最后一条回答:", "stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 455f3c1..82ea723 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -1028,8 +1028,7 @@ var zhTWDict = map[string]string{ "stats.weekday.6": "週六", "stats.weekday.7": "週日", - "stats.foot.dataDate": "數據基準日:", - "stats.foot.updated": "計算於:", + "stats.foot.updated": "快照時間:", "stats.foot.prevMonth": "最後一則提問:", "stats.foot.prevAnswer": "最後一則回答:", "stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。", diff --git a/internal/stats/overview.go b/internal/stats/overview.go index 5e92ab7..8456135 100644 --- a/internal/stats/overview.go +++ b/internal/stats/overview.go @@ -65,8 +65,10 @@ type Overview struct { ReplyBins [6]int64 // 元信息 + // + // UpdatedAt 是这份快照的生成时间,只覆盖累计数字与图表;今日两项在缓存命中时 + // 会被 withToday 换成更晚的读数,所以它不能当成「整页数据的时间」来展示。 UpdatedAt string - DataDate string } // DailyPoint 某一天的提问量。 @@ -300,7 +302,6 @@ func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview { HeatmapDays: raw.HeatmapDays, LengthBins: raw.LengthBins, ReplyBins: raw.ReplyBins, - DataDate: now.Format("2006-01-02"), } if v.Public < 0 { v.Public = 0 diff --git a/templates/stats.html b/templates/stats.html index 326cd9e..81c875d 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -613,8 +613,6 @@
- {{t $.LangCode "stats.foot.dataDate"}} - {{ .DataDate }} {{t $.LangCode "stats.foot.updated"}} {{ .UpdatedAt }} {{t $.LangCode "stats.foot.prevMonth"}} diff --git a/templates/zz_preview_test.go b/templates/zz_preview_test.go index 4c563f5..318a4f4 100644 --- a/templates/zz_preview_test.go +++ b/templates/zz_preview_test.go @@ -243,7 +243,6 @@ func writeStatsPreviews(t *testing.T, outDir string) { Heatmap: heatmap, HeatmapMax: 812, HeatmapDays: 180, LengthBins: [6]int64{8200, 14200, 16800, 9200, 6100, 1821}, ReplyBins: [6]int64{4200, 7100, 8400, 6200, 3400, 1908}, - DataDate: now.Format("2006-01-02"), UpdatedAt: now.Format("2006-01-02") + " 00:07:12", }