From 0c282914c236daab347ac2cb92c7d21ff438bc0e Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 24 Oct 2026 01:20:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(stats):=20=E4=BB=8A=E6=97=A5=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=AF=8F=E6=97=A5=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=86=BB=E7=BB=93=EF=BC=8C=E6=97=A5=E6=9C=9F=E5=88=86?= =?UTF-8?q?=E7=BB=84=E4=B8=8D=E4=BE=9D=E8=B5=96=E5=BA=93=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=97=B6=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查部署结果时发现两处正确性问题,都是本页上线时引入的: 1. 「今日提问 / 今日回答」原本随整页聚合一整天缓存,页面却写着「实时」, 实际当天第一次访问后数字就冻住到次日。现拆出 TodayCounts 单独取, 60 秒 TTL、跨日按日期失效;命中整页缓存时返回副本,不原地改缓存指针 (那会被并发读踩成数据竞争)。 2. 日/月/时分组用 to_char/EXTRACT 直接作用在 timestamptz 上,结果跟着数据库 会话时区走,而 PG 官方镜像默认 UTC、Go 侧按容器时区(Asia/Shanghai)切今天。 实测同一条 2026-10-24 01:30+08 的提问,UTC 会话下被算成 10-23 17 点 —— 日报表整体偏 8 小时、热力图时段错位,且与「今日」对不上。 现统一经 analyticsLocalize() 加 AT TIME ZONE INTERVAL 换算,偏移取自 time.Time.Zone()(Go 未设 TZ 时给 "Local",PG 不认该名,故不用 IANA 名)。 过滤条件仍是裸列,索引照用。 另外两处顺手修: - PeakDay 用 >= 比较,并列时会漂到最近的同值日期,与 busiestSlot 的取值规则不一致。 - 聚合改用 context.WithoutCancel + 30s 超时:第一个访客在聚合途中关掉页面时, 排在后面等锁的请求不会各自重跑一遍全表 GROUP BY。 测试:新增 TestTodayNumbersNotFrozen(缓存命中后今日数仍更新、整页聚合当天只算一次) 与 TestTodayCacheExpiresAcrossMidnight;analytics 连真库通过。 --- .workbuddy/memory/2026-10-24.md | 22 +++++++ .workbuddy/memory/topics/stats.md | 35 +++++++++- internal/db/analytics.go | 103 +++++++++++++++++++++--------- internal/db/analytics_test.go | 9 ++- internal/stats/overview.go | 98 +++++++++++++++++++++++++--- internal/stats/overview_test.go | 70 ++++++++++++++++++++ 6 files changed, 294 insertions(+), 43 deletions(-) diff --git a/.workbuddy/memory/2026-10-24.md b/.workbuddy/memory/2026-10-24.md index 82f7d0e..f8dcb84 100644 --- a/.workbuddy/memory/2026-10-24.md +++ b/.workbuddy/memory/2026-10-24.md @@ -25,3 +25,25 @@ - 页面脚注四条时间(基准日 / 计算于 / 最后一条提问 / 最后一条回答)由 「A:x · B:y」挤成一行改为 `grid-template-columns: max-content 1fr` 各占一行, 标签列与值列对齐;下面两段说明仍为独立段落。四个标签统一带冒号。 + +## 部署前自查:两个 P0 都已修 + +用户部署到测试环境后要求自查。查出的问题都是我自己上一轮引入的: + +1. **「今日」两项被每日缓存冻住**(P0,与页面「实时」说明矛盾)。 + → `TodayCounts` 拆出,60 秒 TTL + 跨日按日期失效;命中缓存时返回副本。 + 新增 `TestTodayNumbersNotFrozen` / `TestTodayCacheExpiresAcrossMidnight`。 +2. **日期分组跟着 PG 会话时区(默认 UTC)**(P0,隐蔽:数字都"合理",只是整体偏 8 小时)。 + psql 实测:`2026-10-24 01:30+08` 在 UTC 会话下 to_char 得 `2026-10-23`、hour=17。 + → `analyticsLocalize()` 用 `AT TIME ZONE INTERVAL '+08:00'`(偏移取 `time.At()` 的 Zone, + 不用 IANA 名)。EXPLAIN 确认过滤仍走 created_at 索引。 +3. PeakDay 用 `>=` 会让「最忙的一天」随更新漂移到最近的同值日期 → 改 `>`。 +4. `ovRefresh` 里的聚合改用 `context.WithoutCancel` + 30s 超时:第一个访客中途关页面时, + 排队的请求不会各自重跑全表 GROUP BY。 + +性能实测(20 万行):totals 337ms / 字数分布 313ms / 热力图 100ms / 日趋势 47ms, +本地合计约 0.8s,1C1G 估 2s,靠预热兜住。百万行才需要合并扫描。 + +顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`, +而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败; +`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。 diff --git a/.workbuddy/memory/topics/stats.md b/.workbuddy/memory/topics/stats.md index ffd4040..0c10b62 100644 --- a/.workbuddy/memory/topics/stats.md +++ b/.workbuddy/memory/topics/stats.md @@ -33,7 +33,40 @@ db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存 - 回答耗时用 `updated_at - created_at`,但**置顶等写操作也会刷新 updated_at**, 所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。 - 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。 -- 累计与图表是当日 0 点快照,「今日提问/回答」两项实时。页面底部已写明。 +- 累计与图表是当日 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 拆成大块。 ## 图表实现(零前端库) diff --git a/internal/db/analytics.go b/internal/db/analytics.go index 2b51932..f1af5f1 100644 --- a/internal/db/analytics.go +++ b/internal/db/analytics.go @@ -19,6 +19,11 @@ import ( // 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。 type SiteAnalyticsStore interface { SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) + // TodayCounts 单独取「今日提问 / 今日回答」。 + // + // 它们刻意不进 SiteAnalytics:整页聚合一整天只算一次,而这两个数要跟着 + // 页面上的「实时」说明走,必须每次(或每隔几十秒)重新取。 + TodayCounts(ctx context.Context, since time.Time) (questions, answers int64, err error) } // Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。 @@ -48,9 +53,6 @@ type SiteAnalytics struct { LengthBins [6]int64 // 提问字数分布 ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问) - - TodayQuestions int64 - TodayAnswers int64 } // CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。 @@ -96,26 +98,59 @@ type analyticsDialect struct { replyMin string } -func analyticsDialectFor(db *gorm.DB) analyticsDialect { +func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect { + tz := analyticsTZOffset(at) + if db.Dialector.Name() == "mysql" { + // MySQL 的 DATETIME 不带时区,驱动按 DSN 的 loc(默认 UTC)写入, + // 因此库里存的是 UTC 墙钟,CONVERT_TZ 再搬到进程时区。用偏移而不是 + // 时区名:CONVERT_TZ 用时区名要求 mysql.time_zone* 表已导入。 + conv := func(col string) string { + return fmt.Sprintf("CONVERT_TZ(%s, '+00:00', '%s')", col, tz) + } + day := conv("created_at") return analyticsDialect{ - day: "DATE_FORMAT(created_at, '%Y-%m-%d')", - month: "DATE_FORMAT(created_at, '%Y-%m')", - dow: "(DAYOFWEEK(created_at) - 1)", - hour: "HOUR(created_at)", + day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day), + month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day), + dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day), + hour: fmt.Sprintf("HOUR(%s)", day), replyMin: "TIMESTAMPDIFF(MINUTE, created_at, updated_at)", } } // PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。 + loc := func(col string) string { return analyticsLocalize(col, at) } return analyticsDialect{ - day: "to_char(created_at, 'YYYY-MM-DD')", - month: "to_char(created_at, 'YYYY-MM')", - dow: "CAST(EXTRACT(DOW FROM created_at) AS INTEGER)", - hour: "CAST(EXTRACT(HOUR FROM created_at) AS INTEGER)", + day: fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", loc("created_at")), + month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", loc("created_at")), + dow: fmt.Sprintf("CAST(EXTRACT(DOW FROM %s) AS INTEGER)", loc("created_at")), + hour: fmt.Sprintf("CAST(EXTRACT(HOUR FROM %s) AS INTEGER)", loc("created_at")), replyMin: "CAST(EXTRACT(EPOCH FROM (updated_at - created_at)) / 60 AS INTEGER)", } } +// analyticsLocalize 把带时区的列转成「Go 进程所在时区的墙钟时间」再参与日期分组。 +// +// 不能依赖数据库会话时区:PG 官方镜像默认 timezone=UTC,而 Go 侧按容器本地时区 +// (Dockerfile 里写的是 Asia/Shanghai)切「今天」。两边不一致时,日报表与热力图会 +// 整体偏 8 小时 —— 中国时间 0–8 点的提问被算进前一天,且和页面顶部「今日」对不上。 +// 这类偏差肉眼看不出来(数字本身都合理),只能靠显式换算消除。 +// +// 用固定偏移而不是 IANA 名:Go 在容器未设 TZ 时会给出 "Local",PG 不认这个名字。 +// 中国无夏令时,固定偏移与真实时区完全等价;有夏令时的部署在窗口内可能差 1 小时。 +func analyticsLocalize(col string, at time.Time) string { + return fmt.Sprintf("%s AT TIME ZONE INTERVAL '%s'", col, analyticsTZOffset(at)) +} + +// analyticsTZOffset 返回 +08:00 这样的偏移字面量。 +func analyticsTZOffset(at time.Time) string { + _, off := at.Zone() + sign := "+" + if off < 0 { + sign, off = "-", -off + } + return fmt.Sprintf("%s%02d:%02d", sign, off/3600, off%3600/60) +} + func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) { loc := now.Location() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) @@ -129,10 +164,10 @@ func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*Sit if err := db.aggregateTotals(ctx, out); err != nil { return nil, err } - if err := db.aggregateTrends(ctx, out, dailySince, monthSince); err != nil { + if err := db.aggregateTrends(ctx, out, now, dailySince, monthSince); err != nil { return nil, err } - if err := db.aggregateHeatmap(ctx, out, heatmapSince); err != nil { + if err := db.aggregateHeatmap(ctx, out, now, heatmapSince); err != nil { return nil, err } if err := db.aggregateContent(ctx, out); err != nil { @@ -146,19 +181,28 @@ func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*Sit return nil, errors.Wrap(err, "count messages") } - // 今日数字单独查:它们不进「每天一次」的缓存窗口,页面顶部要显示实时值。 - if err := db.WithContext(ctx).Model(&Question{}). - Where("created_at >= ?", startOfDay).Count(&out.TodayQuestions).Error; err != nil { - return nil, errors.Wrap(err, "count today's questions") - } - if err := db.WithContext(ctx).Model(&Question{}). - Where("answer <> '' AND updated_at >= ?", startOfDay).Count(&out.TodayAnswers).Error; err != nil { - return nil, errors.Wrap(err, "count today's answers") - } - + // 「今日」两项不在这里查:本函数的结果整天缓存,把会变的数字放进来 + // 会让页面顶着一个「实时」的说明显示一整天不变的值。见 TodayCounts。 return out, nil } +// TodayCounts 取今日提问数与今日回答数,只扫当天数据(走 created_at 索引)。 +// +// 口径:回答数按「今天被回答过」计,用的是 updated_at —— 置顶等写操作也会刷新它, +// 因此这个数偏大。页面只把它当「今天有多少动静」的量级参考,不做精确承诺。 +func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int64, int64, error) { + var questions, answers int64 + if err := db.WithContext(ctx).Model(&Question{}). + Where("created_at >= ?", since).Count(&questions).Error; err != nil { + return 0, 0, errors.Wrap(err, "count today's questions") + } + if err := db.WithContext(ctx).Model(&Question{}). + Where("answer <> '' AND updated_at >= ?", since).Count(&answers).Error; err != nil { + return 0, 0, errors.Wrap(err, "count today's answers") + } + return questions, answers, nil +} + // aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。 // // 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持, @@ -203,8 +247,8 @@ func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics } // aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。 -func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, dailySince, monthSince time.Time) error { - d := analyticsDialectFor(db.DB) +func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, now, dailySince, monthSince time.Time) error { + d := analyticsDialectFor(db.DB, now) var daily []struct { Key string @@ -238,8 +282,8 @@ func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics return nil } -func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, since time.Time) error { - d := analyticsDialectFor(db.DB) +func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, now, since time.Time) error { + d := analyticsDialectFor(db.DB, now) var rows []struct { Dow int @@ -266,7 +310,8 @@ func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytic } func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error { - d := analyticsDialectFor(db.DB) + // 内容分布只用时长差值,不涉及日期分组,时区不影响结果。 + d := analyticsDialectFor(db.DB, time.Now()) var lengths []struct { Bin int diff --git a/internal/db/analytics_test.go b/internal/db/analytics_test.go index d403022..111f4a0 100644 --- a/internal/db/analytics_test.go +++ b/internal/db/analytics_test.go @@ -69,9 +69,12 @@ func TestSiteAnalytics(t *testing.T) { require.EqualValues(t, 1, got.ActiveBoxes) require.EqualValues(t, 1, got.AnsweringBoxes) - // 今日(提问是刚才写的,必然落在今天) - require.EqualValues(t, 2, got.TodayQuestions) - require.EqualValues(t, 1, got.TodayAnswers) + // 今日(提问是刚才写的,必然落在今天)—— 单独取,不随整页聚合缓存 + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayQ, todayA, err := store.TodayCounts(ctx, dayStart) + require.NoError(t, err) + require.EqualValues(t, 2, todayQ) + require.EqualValues(t, 1, todayA) // 日/月趋势里能找到今天与本月 today := now.Format("2006-01-02") diff --git a/internal/stats/overview.go b/internal/stats/overview.go index 5142026..1f593c5 100644 --- a/internal/stats/overview.go +++ b/internal/stats/overview.go @@ -96,20 +96,35 @@ var ( // ovRefresh 串行化重算:一天的第一个请求承担全部聚合,其余请求等它算完直接复用, // 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。 ovRefresh sync.Mutex + + // todayMu 保护「今日」两项的短缓存。 + todayMu sync.Mutex + todayQ int64 + todayA int64 + todayAt time.Time + todayDate string ) +// todayTTL 「今日」两项的缓存时长。 +// +// 不长于 60 秒:页面写的是「实时」,但也不能让每次刷新都打两条 COUNT, +// 否则一个爱刷新的访客就能持续骚扰数据库。 +const todayTTL = 60 * time.Second + // OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。 // // 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天); // 完全没有可用结果才返回错误。 func OverviewData(ctx context.Context) (*Overview, error) { - today := time.Now().Format("2006-01-02") + now := time.Now() + today := now.Format("2006-01-02") ovMu.Lock() cached, day := ovCached, ovDay ovMu.Unlock() if cached != nil && day == today { - return cached, nil + // 命中时也要刷新「今日」两项:它们不属于每日缓存的内容。 + return withToday(ctx, cached, now), nil } ovRefresh.Lock() @@ -120,7 +135,7 @@ func OverviewData(ctx context.Context) (*Overview, error) { cached, day = ovCached, ovDay ovMu.Unlock() if cached != nil && day == today { - return cached, nil + return withToday(ctx, cached, now), nil } if db.Analytics == nil { @@ -128,17 +143,24 @@ func OverviewData(ctx context.Context) (*Overview, error) { return nil, errNoDatabase } - now := time.Now() - raw, err := db.Analytics.SiteAnalytics(ctx, now) + // 聚合用一条独立于请求的 context:第一个访客在聚合途中关掉页面时, + // 请求 ctx 会被取消,排在后面等锁的请求就会各自重跑一遍全表 GROUP BY + // (1C1G 上等于连环超时)。WithoutCancel 保留 trace 等 value、只摘掉取消信号。 + aggCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + + raw, err := db.Analytics.SiteAnalytics(aggCtx, now) if err != nil { - logrus.WithContext(ctx).WithError(err).Warn("计算全站统计数据失败") + logrus.WithContext(aggCtx).WithError(err).Warn("计算全站统计数据失败") if cached != nil { - return cached, nil + return withToday(ctx, cached, now), nil } return nil, err } v := buildOverview(raw, now) + // 今日两项单独取(60 秒缓存),不进整天复用的那份快照。 + v.TodayQuestions, v.TodayAnswers = todayNumbers(ctx, now, 0, 0) ovMu.Lock() ovCached = v @@ -147,12 +169,68 @@ func OverviewData(ctx context.Context) (*Overview, error) { return v, nil } +// withToday 返回一个「今日」两项已更新到最新的副本。 +// +// 必须拷贝而不是原地改 ovCached:缓存里的指针会被多个 goroutine 同时读, +// 原地写字段就是数据竞争。Daily / Monthly / Heatmap 都是只读共享,浅拷贝安全。 +func withToday(ctx context.Context, base *Overview, now time.Time) *Overview { + q, a := todayNumbers(ctx, now, base.TodayQuestions, base.TodayAnswers) + if q == base.TodayQuestions && a == base.TodayAnswers { + return base + } + out := *base + out.TodayQuestions, out.TodayAnswers = q, a + return &out +} + +// todayNumbers 取「今日提问 / 今日回答」,带 60 秒缓存与跨日失效。 +// 查询失败时优先沿用上次的已知值,再退到调用方给的回退值 —— 页面不该因为 +// 两条 COUNT 失败就打不开。 +func todayNumbers(ctx context.Context, now time.Time, fallbackQ, fallbackA int64) (int64, int64) { + date := now.Format("2006-01-02") + + todayMu.Lock() + q, a, at, d := todayQ, todayA, todayAt, todayDate + todayMu.Unlock() + + // 跨日必须先失效:昨天 23:59:50 算出的「今日」在 00:00:30 仍在 TTL 内。 + if d == date && !at.IsZero() && now.Sub(at) < todayTTL { + return q, a + } + if db.Analytics == nil { + return fallbackQ, fallbackA + } + + nq, na, err := db.Analytics.TodayCounts(ctx, time.Date( + now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) + if err != nil { + logrus.WithContext(ctx).WithError(err).Warn("读取今日统计失败") + if !at.IsZero() && d == date { + return q, a + } + return fallbackQ, fallbackA + } + setTodayCache(nq, na, now) + return nq, na +} + +func setTodayCache(q, a int64, now time.Time) { + todayMu.Lock() + todayQ, todayA, todayAt, todayDate = q, a, now, now.Format("2006-01-02") + todayMu.Unlock() +} + // InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。 func InvalidateOverview() { ovMu.Lock() ovCached = nil ovDay = "" ovMu.Unlock() + + // 「今日」两项也要一起丢:留着会让紧随其后的请求在 60 秒内继续看到旧值。 + todayMu.Lock() + todayQ, todayA, todayAt, todayDate = 0, 0, time.Time{}, "" + todayMu.Unlock() } var errNoDatabase = errString("database not initialized") @@ -175,8 +253,6 @@ func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview { ActiveBoxes: raw.ActiveBoxes, AnsweringBoxes: raw.AnsweringBoxes, TotalMessages: raw.TotalMessages, - TodayQuestions: raw.TodayQuestions, - TodayAnswers: raw.TodayAnswers, Heatmap: raw.Heatmap, HeatmapDays: raw.HeatmapDays, LengthBins: raw.LengthBins, @@ -211,7 +287,9 @@ func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview { if p.N > v.DailyMax { v.DailyMax = p.N } - if p.N >= v.PeakDay.N { + // 用严格大于:并列时保留更早的一天,与 busiestSlot 的取值规则一致 + // (>= 会让「最忙的一天」随更新漂移到最近一个同值日期)。 + if p.N > v.PeakDay.N { v.PeakDay = p } } diff --git a/internal/stats/overview_test.go b/internal/stats/overview_test.go index c316722..40df6b7 100644 --- a/internal/stats/overview_test.go +++ b/internal/stats/overview_test.go @@ -5,6 +5,7 @@ package stats import ( + "context" "strings" "testing" "time" @@ -110,3 +111,72 @@ func TestOverviewDerived(t *testing.T) { // 复用到句子里会读成「提问最集中在三 8 点前后」。 require.Contains(t, strings.Join(page.Insights, "\n"), "提问最集中在周一 8 点前后。") } + +// fakeAnalytics 最小实现,用来卡住缓存的边界: +// 整页聚合一天只该算一次,但「今日」两项必须跟着每次请求走。 +type fakeAnalytics struct { + todayQ, todayA int64 + siteCalls int +} + +func (f *fakeAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) { + f.siteCalls++ + return &db.SiteAnalytics{TotalQuestions: 10, Answered: 4}, nil +} + +func (f *fakeAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) { + return f.todayQ, f.todayA, nil +} + +// TestTodayNumbersNotFrozen 回归:「今日」两项曾被塞进每日缓存, +// 导致页面顶着「实时」的说明一整天显示常量。 +func TestTodayNumbersNotFrozen(t *testing.T) { + prev := db.Analytics + t.Cleanup(func() { + db.Analytics = prev + InvalidateOverview() + }) + + f := &fakeAnalytics{todayQ: 3, todayA: 1} + db.Analytics = f + InvalidateOverview() + + first, err := OverviewData(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 3, first.TodayQuestions) + + // 今天又来了几条,且短缓存已过期 + f.todayQ, f.todayA = 8, 2 + todayMu.Lock() + todayAt = todayAt.Add(-2 * todayTTL) + todayMu.Unlock() + + second, err := OverviewData(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 8, second.TodayQuestions, "今日数不能被每日缓存冻结") + require.EqualValues(t, 2, second.TodayAnswers) + require.Equal(t, 1, f.siteCalls, "整页聚合当天只应算一次") + + // 返回的是副本,不能把已经交出去的 pages 改掉 + require.EqualValues(t, 3, first.TodayQuestions) +} + +// TestTodayCacheExpiresAcrossMidnight 跨日必须重算:昨天 23:59 算出的是昨天的数。 +func TestTodayCacheExpiresAcrossMidnight(t *testing.T) { + prev := db.Analytics + t.Cleanup(func() { + db.Analytics = prev + InvalidateOverview() + }) + + f := &fakeAnalytics{todayQ: 7, todayA: 5} + db.Analytics = f + + justAfterMidnight := time.Date(2026, 10, 24, 0, 0, 30, 0, time.Local) + // 昨天午夜算出来的缓存(日期不同,即使还在 60 秒内也不该复用) + setTodayCache(99, 99, justAfterMidnight.Add(-24*time.Hour)) + + q, a := todayNumbers(context.Background(), justAfterMidnight, 0, 0) + require.EqualValues(t, 7, q, "跨日后必须重算,不能沿用昨天的数字") + require.EqualValues(t, 5, a) +}