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 桩的既有问题)。
This commit is contained in:
@@ -47,3 +47,31 @@
|
|||||||
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
|
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
|
||||||
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
|
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
|
||||||
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
|
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
|
||||||
|
|
||||||
|
## 测试环境实测(box-dev-api.337845818.best,用户时区 UTC+8)
|
||||||
|
|
||||||
|
- `/stats` 200,首访 5.2s(含 TLS),稳定后 TTFB 1.6s,同站 `/rules` 1.34s
|
||||||
|
→ 本页只多约 250ms,1C1G 上可接受,无需合并聚合 SQL。
|
||||||
|
- 环境默认语言是 ja(`/`、`/rules` 同样是 ja),与本次改动无关;语言存 session
|
||||||
|
(`/i18n/{lang}`),故 /stats **绝不能加 CDN 缓存头** —— 内容随 session 变化且无 Vary: Cookie。
|
||||||
|
- 逐项验算自洽:月柱合计 95 = 累计提问;热力图 7×24=168 格合计 95;
|
||||||
|
最忙时段周三 19 点 = 22 与小时合计 26 峰值一致;折线 30 点峰值 10-19 共 5 条。
|
||||||
|
- `/stats/` 尾斜杠 302 跳首页 —— 与 `/rules/`、`/sponsor/` 一致,框架行为。
|
||||||
|
|
||||||
|
## P0:回答耗时口径是错的(本次修)
|
||||||
|
|
||||||
|
`SetRead` 把 updated_at 往前推(实测 10 天前的提问,delta = 240h)。
|
||||||
|
所以「回答耗时 = updated_at − created_at」实际测的是「提问到最后一次被碰」,
|
||||||
|
线上「6 条超过 7 天」就是这么来的,24 小时占比 66.7% 同样偏低。
|
||||||
|
|
||||||
|
修法:`questions.answered_at`(首次回答时间),只在 answer 由空变非空时写,
|
||||||
|
改写回答不重置;聚合 `COALESCE(answered_at, updated_at)`;加列时回填一次冻结历史。
|
||||||
|
测试:`internal/db/answered_at_test.go` 5 个用例,含变异验证(改回 updated_at 即红)。
|
||||||
|
|
||||||
|
## 顺带修
|
||||||
|
|
||||||
|
- 环比句式:中文「比前一个月 -69.6%」、英文「...: -69.6% questions.」读不通 →
|
||||||
|
「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
|
||||||
|
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
|
||||||
|
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
|
||||||
|
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
|
||||||
|
|||||||
@@ -30,10 +30,16 @@ db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
|
|||||||
|
|
||||||
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
|
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
|
||||||
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
|
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
|
||||||
- 回答耗时用 `updated_at - created_at`,但**置顶等写操作也会刷新 updated_at**,
|
- 回答耗时用 `answered_at - created_at`(**不是 updated_at**)。GORM 的 `Update`
|
||||||
所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。
|
会自动刷新 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%。
|
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
|
||||||
- 累计与图表是当日 0 点快照,「今日提问/回答」两项实时(60 秒 TTL,见下)。
|
句式用「上月提问量环比 %s%%」(带符号),不要写「比前一个月 -69.6%」。
|
||||||
|
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
|
||||||
|
「今日提问/回答」两项实时(60 秒 TTL,见下)。
|
||||||
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
|
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
|
||||||
不能依赖数据库会话时区 —— 见「时区」一节。
|
不能依赖数据库会话时区 —— 见「时区」一节。
|
||||||
|
|
||||||
|
|||||||
@@ -110,11 +110,13 @@ func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
|
|||||||
}
|
}
|
||||||
day := conv("created_at")
|
day := conv("created_at")
|
||||||
return analyticsDialect{
|
return analyticsDialect{
|
||||||
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
|
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
|
||||||
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
|
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
|
||||||
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
|
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
|
||||||
hour: fmt.Sprintf("HOUR(%s)", day),
|
hour: fmt.Sprintf("HOUR(%s)", day),
|
||||||
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, updated_at)",
|
// 回答耗时以 answered_at 为准。历史行该列为空(加列之前回答的),
|
||||||
|
// 只能退回 updated_at —— 那部分含已读/置顶带来的误差,无法追溯修正。
|
||||||
|
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, COALESCE(answered_at, updated_at))",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
|
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
|
||||||
@@ -124,7 +126,7 @@ func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
|
|||||||
month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", 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")),
|
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")),
|
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)",
|
replyMin: "CAST(EXTRACT(EPOCH FROM (COALESCE(answered_at, updated_at) - created_at)) / 60 AS INTEGER)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +230,7 @@ func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics
|
|||||||
COUNT(DISTINCT user_id) AS active_boxes,
|
COUNT(DISTINCT user_id) AS active_boxes,
|
||||||
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
|
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
|
||||||
MAX(created_at) AS last_q,
|
MAX(created_at) AS last_q,
|
||||||
MAX(CASE WHEN answer <> '' THEN updated_at END) AS last_a`).
|
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "aggregate question totals")
|
return errors.Wrap(err, "aggregate question totals")
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright 2022 E99p1ant. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAnsweredAtOnlySetOnFirstAnswer 回答时间只记一次。
|
||||||
|
//
|
||||||
|
// 背景:GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换
|
||||||
|
// 都走 Update。若拿 updated_at 当回答时间,回答完再点一次「已读」,
|
||||||
|
// 耗时就会从几分钟变成几十天 —— 线上那份「6 条超过 7 天」的数据就是这么来的。
|
||||||
|
func TestAnsweredAtOnlySetOnFirstAnswer(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("questions")
|
||||||
|
|
||||||
|
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
|
||||||
|
q := Question{UserID: 1, Content: "hello", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||||
|
require.NoError(t, conn.Create(&q).Error)
|
||||||
|
|
||||||
|
store := NewQuestionsStore(conn)
|
||||||
|
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "first"))
|
||||||
|
|
||||||
|
var got Question
|
||||||
|
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||||
|
require.NotNil(t, got.AnsweredAt, "首次回答必须记下 answered_at")
|
||||||
|
firstAt := *got.AnsweredAt
|
||||||
|
require.WithinDuration(t, time.Now(), firstAt, time.Minute)
|
||||||
|
|
||||||
|
// 改写回答:answered_at 不能被重置,否则「回答耗时」会跟着编辑动作变大。
|
||||||
|
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "edited"))
|
||||||
|
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||||
|
require.NotNil(t, got.AnsweredAt)
|
||||||
|
require.Equal(t, firstAt.Unix(), got.AnsweredAt.Unix(), "改写回答不该重置 answered_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnsweredAtSetWhenAnsweringAsPrivate 强制私密那条 UPDATE 同样要记时间。
|
||||||
|
func TestAnsweredAtSetWhenAnsweringAsPrivate(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("questions")
|
||||||
|
|
||||||
|
q := Question{UserID: 1, Content: "secret"}
|
||||||
|
require.NoError(t, conn.Create(&q).Error)
|
||||||
|
|
||||||
|
require.NoError(t, NewQuestionsStore(conn).AnswerByIDPrivate(context.Background(), q.ID, "answer"))
|
||||||
|
|
||||||
|
var got Question
|
||||||
|
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||||
|
require.NotNil(t, got.AnsweredAt)
|
||||||
|
require.True(t, got.IsPrivate)
|
||||||
|
require.True(t, got.IsRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplyBinsUseAnsweredAt 回答耗时以 answered_at 为准,不受后来的「已读」影响。
|
||||||
|
//
|
||||||
|
// 构造:72 小时前提问、10 分钟前回答(落第一档);但 updated_at 被改成当下
|
||||||
|
// (相当于回答完又被读了一次),差值是 72 小时 = 4320 分钟,正好落到第四档。
|
||||||
|
// 若仍在用 updated_at,这条会进第四档而不是第一档。
|
||||||
|
func TestReplyBinsUseAnsweredAt(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("questions")
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
createdAt := now.Add(-72 * time.Hour)
|
||||||
|
answeredAt := createdAt.Add(10 * time.Minute) // 提问后 10 分钟回答 → 第一档
|
||||||
|
q := Question{UserID: 1, Content: "hi", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||||
|
require.NoError(t, conn.Create(&q).Error)
|
||||||
|
|
||||||
|
// 直接写列,绕开 GORM 的 UpdatedAt 自动更新。
|
||||||
|
require.NoError(t, conn.Exec(
|
||||||
|
"UPDATE questions SET answer = ?, is_read = ?, answered_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
"answer", true, answeredAt, now, q.ID).Error)
|
||||||
|
|
||||||
|
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
for _, n := range got.ReplyBins {
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
require.EqualValues(t, 1, total)
|
||||||
|
require.EqualValues(t, 1, got.ReplyBins[0], "10 分钟的回答应落在第一档")
|
||||||
|
require.EqualValues(t, 0, got.ReplyBins[4], "不该被 pushed 到当下的 updated_at 带偏")
|
||||||
|
|
||||||
|
// 「最后一条回答」同理:取 answered_at 而不是被刷新过的 updated_at。
|
||||||
|
require.NotNil(t, got.LastAnswerAt)
|
||||||
|
require.WithinDuration(t, answeredAt, *got.LastAnswerAt, time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplyBinsFallBackToUpdatedAt 加列之前回答的历史行 answered_at 为空,
|
||||||
|
// 退回 updated_at,不能让这些提问从分布里消失。
|
||||||
|
func TestReplyBinsFallBackToUpdatedAt(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("questions")
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// 10 天前提问、9 天前处理:差值 24 小时(1440 分钟),落在第三档。
|
||||||
|
createdAt := now.Add(-10 * 24 * time.Hour)
|
||||||
|
updatedAt := now.Add(-9 * 24 * time.Hour)
|
||||||
|
q := Question{UserID: 1, Content: "old", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||||
|
require.NoError(t, conn.Create(&q).Error)
|
||||||
|
require.NoError(t, conn.Exec(
|
||||||
|
"UPDATE questions SET answer = ?, answered_at = NULL, updated_at = ? WHERE id = ?",
|
||||||
|
"old answer", updatedAt, q.ID).Error)
|
||||||
|
|
||||||
|
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, got.Answered)
|
||||||
|
require.EqualValues(t, 1, got.ReplyBins[3], "历史行应退回 updated_at 计入分布")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackfillAnsweredAt 历史行回填:拿 updated_at 冻结一次,之后不再随已读漂移。
|
||||||
|
func TestBackfillAnsweredAt(t *testing.T) {
|
||||||
|
conn, clean := newTestDB(t)
|
||||||
|
defer clean("questions")
|
||||||
|
|
||||||
|
// 列由 AutoMigrate 建好,HasColumn 必须能认出来(启动时靠它判断「是不是刚加列」)。
|
||||||
|
require.True(t, conn.Migrator().HasColumn(&Question{}, "answered_at"))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
createdAt := now.Add(-20 * 24 * time.Hour)
|
||||||
|
answeredAtOld := now.Add(-19 * 24 * time.Hour)
|
||||||
|
|
||||||
|
// 一条历史已回答(answered_at 为空),一条未回答(不该被回填)。
|
||||||
|
require.NoError(t, conn.Exec(
|
||||||
|
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'old','a',false,true,false,?,?)",
|
||||||
|
createdAt, answeredAtOld).Error)
|
||||||
|
require.NoError(t, conn.Exec(
|
||||||
|
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'open','',false,false,false,?,?)",
|
||||||
|
createdAt, answeredAtOld).Error)
|
||||||
|
|
||||||
|
require.NoError(t, backfillAnsweredAt(conn))
|
||||||
|
|
||||||
|
var answered Question
|
||||||
|
require.NoError(t, conn.Where("content = ?", "old").First(&answered).Error)
|
||||||
|
require.NotNil(t, answered.AnsweredAt)
|
||||||
|
require.WithinDuration(t, answeredAtOld, *answered.AnsweredAt, time.Second)
|
||||||
|
|
||||||
|
var open Question
|
||||||
|
require.NoError(t, conn.Where("content = ?", "open").First(&open).Error)
|
||||||
|
require.Nil(t, open.AnsweredAt, "未回答的提问不该被回填")
|
||||||
|
}
|
||||||
@@ -66,10 +66,25 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// questions.answered_at 是 2026-10 新增的列(首次回答时间,用于算「回答耗时」)。
|
||||||
|
// 加列之前已回答的提问没有这个值,只能事后拿 updated_at 近似。
|
||||||
|
// 必须在 AutoMigrate 之前判断列是否存在 —— 迁移之后大家都有列了,就分不出谁是历史行。
|
||||||
|
hasAnsweredAt := db.Migrator().HasColumn(&Question{}, "answered_at")
|
||||||
|
|
||||||
if err := db.AutoMigrate(AllTables...); err != nil {
|
if err := db.AutoMigrate(AllTables...); err != nil {
|
||||||
return nil, errors.Wrap(err, "auto migrate")
|
return nil, errors.Wrap(err, "auto migrate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 回填只在「这次刚加上列」时做一次。
|
||||||
|
// 为什么必须回填:这些行的 updated_at 之后还会被已读/置顶继续往前推,
|
||||||
|
// 不回填的话历史「回答耗时」会一天天变大,看起来像统计出错。
|
||||||
|
// 失败不阻断启动,页面会退回 updated_at 计算。
|
||||||
|
if !hasAnsweredAt {
|
||||||
|
if err := backfillAnsweredAt(db); err != nil {
|
||||||
|
logrus.WithError(err).Warn("回填 questions.answered_at 时异常(历史回答耗时将退回 updated_at)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
|
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
|
||||||
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
|
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
|
||||||
if err := backfillFeatureControls(db); err != nil {
|
if err := backfillFeatureControls(db); err != nil {
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ type Question struct {
|
|||||||
AskerUserID uint `json:"-"`
|
AskerUserID uint `json:"-"`
|
||||||
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
|
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
|
||||||
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
|
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
|
||||||
|
// AnsweredAt 首次回答的时间,用于算「回答耗时」。为空表示还没回答过。
|
||||||
|
//
|
||||||
|
// 不能拿 updated_at 当回答时间:GORM 的 Update 会自动刷新 updated_at,
|
||||||
|
// 而已读、置顶、公开/私密切换都走 Update,回答之后再点一次「已读」
|
||||||
|
// 就会把耗时撑大(实测:一条 10 天前的提问被 SetRead 后 updated_at 直接推到当下)。
|
||||||
|
// 只在 answer 由空变非空时写一次,改写回答不重置。
|
||||||
|
AnsweredAt *time.Time `json:"-"`
|
||||||
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
|
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
|
||||||
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
|
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
|
||||||
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
|
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
|
||||||
@@ -391,7 +398,7 @@ func (db *questions) AnswerByIDPrivate(ctx context.Context, id uint, answer stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{"answer": answer, "is_read": true, "is_private": true}).Error; err != nil {
|
Updates(answerUpdates(question.Answer, answer, map[string]interface{}{"is_private": true})).Error; err != nil {
|
||||||
return errors.Wrap(err, "update question answer as private")
|
return errors.Wrap(err, "update question answer as private")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -407,12 +414,28 @@ func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{"answer": answer, "is_read": true}).Error; err != nil {
|
Updates(answerUpdates(question.Answer, answer, nil)).Error; err != nil {
|
||||||
return errors.Wrap(err, "update question answer")
|
return errors.Wrap(err, "update question answer")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// answerUpdates 组装回答写入的字段集合。
|
||||||
|
//
|
||||||
|
// 只有「原本没回答」时才写 answered_at:改写已有回答不该重置首次回答时间,
|
||||||
|
// 否则「回答耗时」会跟着编辑动作变大。extra 用于合并调用方自己的附加字段
|
||||||
|
// (如强制私密),保持仍然只有一条 UPDATE。
|
||||||
|
func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
updates := map[string]interface{}{"answer": answer, "is_read": true}
|
||||||
|
for k, v := range extra {
|
||||||
|
updates[k] = v
|
||||||
|
}
|
||||||
|
if prevAnswer == "" {
|
||||||
|
updates["answered_at"] = time.Now()
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
|
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
|
||||||
var question Question
|
var question Question
|
||||||
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
|
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
|
||||||
@@ -591,3 +614,14 @@ func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error)
|
|||||||
LastAnswerAt: t.LastA,
|
LastAnswerAt: t.LastA,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillAnsweredAt 为「加列之前就已经回答」的提问冻结回答时间。
|
||||||
|
//
|
||||||
|
// 只能在刚加上 answered_at 这一列时跑一次(调用方按 HasColumn 判断)。
|
||||||
|
// 取值用 updated_at 当近似 —— 那是当时唯一能拿到的可信时间。
|
||||||
|
// 之后这些行的 updated_at 还会被已读/置顶继续往前推,冻结住才不会让
|
||||||
|
// 历史「回答耗时」一天天变大,看着像统计出错。
|
||||||
|
func backfillAnsweredAt(db *gorm.DB) error {
|
||||||
|
return db.Exec(
|
||||||
|
`UPDATE questions SET answered_at = updated_at WHERE answer <> '' AND answered_at IS NULL`).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -975,7 +975,7 @@ var enDict = map[string]string{
|
|||||||
"stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.",
|
"stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.",
|
||||||
"stats.insight.peak": "Busiest day was %s with %s questions.",
|
"stats.insight.peak": "Busiest day was %s with %s questions.",
|
||||||
"stats.insight.busiest": "Questions cluster around %s %d:00.",
|
"stats.insight.busiest": "Questions cluster around %s %d:00.",
|
||||||
"stats.insight.mom": "Last month versus the month before: %s%% questions.",
|
"stats.insight.mom": "Month-over-month change for last month: %s%%.",
|
||||||
"stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.",
|
"stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.",
|
||||||
"stats.insight.private": "%s%% of questions were sent privately.",
|
"stats.insight.private": "%s%% of questions were sent privately.",
|
||||||
|
|
||||||
@@ -1034,5 +1034,5 @@ var enDict = map[string]string{
|
|||||||
"stats.foot.prevMonth": "Last question:",
|
"stats.foot.prevMonth": "Last question:",
|
||||||
"stats.foot.prevAnswer": "Last answer:",
|
"stats.foot.prevAnswer": "Last answer:",
|
||||||
"stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.",
|
"stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.",
|
||||||
"stats.foot.cacheNote": "Totals and charts are a snapshot taken at 00:00 local time, refreshed once a day; the two \"today\" figures are live.",
|
"stats.foot.cacheNote": "Totals and charts are recomputed once a day, on the first visit after midnight (and again after a restart); the two \"today\" figures are live.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -974,7 +974,7 @@ var jaDict = map[string]string{
|
|||||||
"stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。",
|
"stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。",
|
||||||
"stats.insight.peak": "最も多かった日は %s で %s 件でした。",
|
"stats.insight.peak": "最も多かった日は %s で %s 件でした。",
|
||||||
"stats.insight.busiest": "質問は %s %d 時ごろに集中しています。",
|
"stats.insight.busiest": "質問は %s %d 時ごろに集中しています。",
|
||||||
"stats.insight.mom": "先月は前月比 %s%%。",
|
"stats.insight.mom": "先月の質問数は前月比 %s%%。",
|
||||||
"stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。",
|
"stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。",
|
||||||
"stats.insight.private": "質問の %s%% は非公開が選ばれています。",
|
"stats.insight.private": "質問の %s%% は非公開が選ばれています。",
|
||||||
|
|
||||||
@@ -1033,5 +1033,5 @@ var jaDict = map[string]string{
|
|||||||
"stats.foot.prevMonth": "最後の質問:",
|
"stats.foot.prevMonth": "最後の質問:",
|
||||||
"stats.foot.prevAnswer": "最後の回答:",
|
"stats.foot.prevAnswer": "最後の回答:",
|
||||||
"stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。",
|
"stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。",
|
||||||
"stats.foot.cacheNote": "累計値とグラフは当日 0 時のスナップショットで、1 日 1 回だけ更新されます。「今日」の 2 項目はリアルタイムです。",
|
"stats.foot.cacheNote": "累計値とグラフはその日の最初のアクセス時に 1 回だけ再計算されます(再起動時にも再計算)。「今日」の 2 項目はリアルタイムです。",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -975,7 +975,7 @@ var zhCNDict = map[string]string{
|
|||||||
"stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。",
|
"stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。",
|
||||||
"stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。",
|
"stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。",
|
||||||
"stats.insight.busiest": "提问最集中在%s %d 点前后。",
|
"stats.insight.busiest": "提问最集中在%s %d 点前后。",
|
||||||
"stats.insight.mom": "上月提问量比前一个月 %s%%。",
|
"stats.insight.mom": "上月提问量环比 %s%%。",
|
||||||
"stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。",
|
"stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。",
|
||||||
"stats.insight.private": "%s%% 的提问选择了私密(不公开)。",
|
"stats.insight.private": "%s%% 的提问选择了私密(不公开)。",
|
||||||
|
|
||||||
@@ -1034,5 +1034,5 @@ var zhCNDict = map[string]string{
|
|||||||
"stats.foot.prevMonth": "最后一条提问:",
|
"stats.foot.prevMonth": "最后一条提问:",
|
||||||
"stats.foot.prevAnswer": "最后一条回答:",
|
"stats.foot.prevAnswer": "最后一条回答:",
|
||||||
"stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。",
|
"stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。",
|
||||||
"stats.foot.cacheNote": "累计数字与图表是当日 0 点的快照,每天只更新一次;「今日」两项为实时数字。",
|
"stats.foot.cacheNote": "累计数字与图表是当天重算一次的快照(当天第一次访问时算,服务重启也会重算);「今日」两项为实时数字。",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -974,7 +974,7 @@ var zhTWDict = map[string]string{
|
|||||||
"stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。",
|
"stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。",
|
||||||
"stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。",
|
"stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。",
|
||||||
"stats.insight.busiest": "提問最集中在%s %d 點前後。",
|
"stats.insight.busiest": "提問最集中在%s %d 點前後。",
|
||||||
"stats.insight.mom": "上月提問量比前一個月 %s%%。",
|
"stats.insight.mom": "上月提問量環比 %s%%。",
|
||||||
"stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。",
|
"stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。",
|
||||||
"stats.insight.private": "%s%% 的提問選擇了私密(不公開)。",
|
"stats.insight.private": "%s%% 的提問選擇了私密(不公開)。",
|
||||||
|
|
||||||
@@ -1033,5 +1033,5 @@ var zhTWDict = map[string]string{
|
|||||||
"stats.foot.prevMonth": "最後一則提問:",
|
"stats.foot.prevMonth": "最後一則提問:",
|
||||||
"stats.foot.prevAnswer": "最後一則回答:",
|
"stats.foot.prevAnswer": "最後一則回答:",
|
||||||
"stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。",
|
"stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。",
|
||||||
"stats.foot.cacheNote": "累計數字與圖表是當日 0 點的快照,每天只更新一次;「今日」兩項為即時數字。",
|
"stats.foot.cacheNote": "累計數字與圖表是當天重算一次的快照(當天第一次造訪時算,服務重啟也會重算);「今日」兩項為即時數字。",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type Overview struct {
|
|||||||
FastReplyRate float64 // 24 小时内获得回答的比例,百分比
|
FastReplyRate float64 // 24 小时内获得回答的比例,百分比
|
||||||
Total30 int64 // 近 30 天提问总数
|
Total30 int64 // 近 30 天提问总数
|
||||||
AvgDailyLast30 float64 // 近 30 天日均提问
|
AvgDailyLast30 float64 // 近 30 天日均提问
|
||||||
GrowthMoM float64 // 本月提问量相对上月的环比,百分比(上月为 0 时置 0)
|
GrowthMoM float64 // 上月相对上上月的环比,百分比(上上月为 0 时不展示)
|
||||||
HasPrevMonth bool // 上月是否有数据,决定要不要展示环比
|
HasPrevMonth bool // 上月是否有数据,决定要不要展示环比
|
||||||
|
|
||||||
// 今日实时(不参与每日缓存)
|
// 今日实时(不参与每日缓存)
|
||||||
@@ -97,6 +97,14 @@ var (
|
|||||||
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
|
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
|
||||||
ovRefresh sync.Mutex
|
ovRefresh sync.Mutex
|
||||||
|
|
||||||
|
// ovFailAt / ovFailErr 记录最近一次聚合失败。
|
||||||
|
//
|
||||||
|
// 没有它的话,数据库挂起时(连接不断但查询不返回)每个排队的请求都要各自耗满
|
||||||
|
// 30 秒超时:第 N 个请求要等 N×30 秒,页面对所有人一起超时。
|
||||||
|
// 冷却期内直接返回失败(有旧快照就先用旧的),不再碰数据库。
|
||||||
|
ovFailAt time.Time
|
||||||
|
ovFailErr error
|
||||||
|
|
||||||
// todayMu 保护「今日」两项的短缓存。
|
// todayMu 保护「今日」两项的短缓存。
|
||||||
todayMu sync.Mutex
|
todayMu sync.Mutex
|
||||||
todayQ int64
|
todayQ int64
|
||||||
@@ -120,23 +128,29 @@ func OverviewData(ctx context.Context) (*Overview, error) {
|
|||||||
today := now.Format("2006-01-02")
|
today := now.Format("2006-01-02")
|
||||||
|
|
||||||
ovMu.Lock()
|
ovMu.Lock()
|
||||||
cached, day := ovCached, ovDay
|
cached, day, failAt, failErr := ovCached, ovDay, ovFailAt, ovFailErr
|
||||||
ovMu.Unlock()
|
ovMu.Unlock()
|
||||||
if cached != nil && day == today {
|
if cached != nil && day == today {
|
||||||
// 命中时也要刷新「今日」两项:它们不属于每日缓存的内容。
|
// 命中时也要刷新「今日」两项:它们不属于每日缓存的内容。
|
||||||
return withToday(ctx, cached, now), nil
|
return withToday(ctx, cached, now), nil
|
||||||
}
|
}
|
||||||
|
if inCooldown(now, failAt) {
|
||||||
|
return degraded(cached, failErr)
|
||||||
|
}
|
||||||
|
|
||||||
ovRefresh.Lock()
|
ovRefresh.Lock()
|
||||||
defer ovRefresh.Unlock()
|
defer ovRefresh.Unlock()
|
||||||
|
|
||||||
// 二次检查:等锁期间可能已有别的请求刷新过。
|
// 二次检查:等锁期间可能已有别的请求刷新过,或是刚失败过。
|
||||||
ovMu.Lock()
|
ovMu.Lock()
|
||||||
cached, day = ovCached, ovDay
|
cached, day, failAt, failErr = ovCached, ovDay, ovFailAt, ovFailErr
|
||||||
ovMu.Unlock()
|
ovMu.Unlock()
|
||||||
if cached != nil && day == today {
|
if cached != nil && day == today {
|
||||||
return withToday(ctx, cached, now), nil
|
return withToday(ctx, cached, now), nil
|
||||||
}
|
}
|
||||||
|
if inCooldown(now, failAt) {
|
||||||
|
return degraded(cached, failErr)
|
||||||
|
}
|
||||||
|
|
||||||
if db.Analytics == nil {
|
if db.Analytics == nil {
|
||||||
// 单测里没有初始化数据库时不应 panic,交给调用方回退。
|
// 单测里没有初始化数据库时不应 panic,交给调用方回退。
|
||||||
@@ -152,6 +166,9 @@ func OverviewData(ctx context.Context) (*Overview, error) {
|
|||||||
raw, err := db.Analytics.SiteAnalytics(aggCtx, now)
|
raw, err := db.Analytics.SiteAnalytics(aggCtx, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithContext(aggCtx).WithError(err).Warn("计算全站统计数据失败")
|
logrus.WithContext(aggCtx).WithError(err).Warn("计算全站统计数据失败")
|
||||||
|
ovMu.Lock()
|
||||||
|
ovFailAt, ovFailErr = now, err
|
||||||
|
ovMu.Unlock()
|
||||||
if cached != nil {
|
if cached != nil {
|
||||||
return withToday(ctx, cached, now), nil
|
return withToday(ctx, cached, now), nil
|
||||||
}
|
}
|
||||||
@@ -165,10 +182,31 @@ func OverviewData(ctx context.Context) (*Overview, error) {
|
|||||||
ovMu.Lock()
|
ovMu.Lock()
|
||||||
ovCached = v
|
ovCached = v
|
||||||
ovDay = today
|
ovDay = today
|
||||||
|
ovFailAt, ovFailErr = time.Time{}, nil
|
||||||
ovMu.Unlock()
|
ovMu.Unlock()
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ovFailCooldown 聚合失败后多久内不再重试。
|
||||||
|
//
|
||||||
|
// 取 30 秒:够数据库扛过一次抖动,又能让恢复后的第一个请求在一分钟内拿到新数据。
|
||||||
|
const ovFailCooldown = 30 * time.Second
|
||||||
|
|
||||||
|
func inCooldown(now, failAt time.Time) bool {
|
||||||
|
return !failAt.IsZero() && now.Sub(failAt) < ovFailCooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
// degraded 按「有旧快照就用旧的」降级:页面照常打开,只是数据停在上一份。
|
||||||
|
func degraded(cached *Overview, err error) (*Overview, error) {
|
||||||
|
if cached != nil {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = errNoDatabase
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// withToday 返回一个「今日」两项已更新到最新的副本。
|
// withToday 返回一个「今日」两项已更新到最新的副本。
|
||||||
//
|
//
|
||||||
// 必须拷贝而不是原地改 ovCached:缓存里的指针会被多个 goroutine 同时读,
|
// 必须拷贝而不是原地改 ovCached:缓存里的指针会被多个 goroutine 同时读,
|
||||||
@@ -205,9 +243,13 @@ func todayNumbers(ctx context.Context, now time.Time, fallbackQ, fallbackA int64
|
|||||||
now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()))
|
now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.WithContext(ctx).WithError(err).Warn("读取今日统计失败")
|
logrus.WithContext(ctx).WithError(err).Warn("读取今日统计失败")
|
||||||
|
// 失败也要盖时间戳:数据库故障时每个请求都打两条 COUNT 只会雪上加霜。
|
||||||
|
// 期间沿用同一天的旧值,跨日时用调用方给的回退值(昨天的数不是今天的数)。
|
||||||
if !at.IsZero() && d == date {
|
if !at.IsZero() && d == date {
|
||||||
|
setTodayCache(q, a, now)
|
||||||
return q, a
|
return q, a
|
||||||
}
|
}
|
||||||
|
setTodayCache(fallbackQ, fallbackA, now)
|
||||||
return fallbackQ, fallbackA
|
return fallbackQ, fallbackA
|
||||||
}
|
}
|
||||||
setTodayCache(nq, na, now)
|
setTodayCache(nq, na, now)
|
||||||
@@ -225,6 +267,7 @@ func InvalidateOverview() {
|
|||||||
ovMu.Lock()
|
ovMu.Lock()
|
||||||
ovCached = nil
|
ovCached = nil
|
||||||
ovDay = ""
|
ovDay = ""
|
||||||
|
ovFailAt, ovFailErr = time.Time{}, nil
|
||||||
ovMu.Unlock()
|
ovMu.Unlock()
|
||||||
|
|
||||||
// 「今日」两项也要一起丢:留着会让紧随其后的请求在 60 秒内继续看到旧值。
|
// 「今日」两项也要一起丢:留着会让紧随其后的请求在 60 秒内继续看到旧值。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package stats
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -180,3 +181,100 @@ func TestTodayCacheExpiresAcrossMidnight(t *testing.T) {
|
|||||||
require.EqualValues(t, 7, q, "跨日后必须重算,不能沿用昨天的数字")
|
require.EqualValues(t, 7, q, "跨日后必须重算,不能沿用昨天的数字")
|
||||||
require.EqualValues(t, 5, a)
|
require.EqualValues(t, 5, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// failingAnalytics 模拟数据库挂掉:每次调用都报错,并记录被调用次数。
|
||||||
|
type failingAnalytics struct {
|
||||||
|
siteCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) {
|
||||||
|
f.siteCalls++
|
||||||
|
return nil, errors.New("db is down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) {
|
||||||
|
return 0, 0, errors.New("db is down")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewFailureCooldown 聚合失败后要冷却,不能每个请求都重排一次全表聚合。
|
||||||
|
//
|
||||||
|
// 没有冷却时,数据库挂起(连接不断、查询不返回)会让排队请求各自耗满 30 秒超时,
|
||||||
|
// 第 N 个请求要等 N×30 秒 —— 页面对所有人一起超时。
|
||||||
|
func TestOverviewFailureCooldown(t *testing.T) {
|
||||||
|
prev := db.Analytics
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Analytics = prev
|
||||||
|
InvalidateOverview()
|
||||||
|
})
|
||||||
|
|
||||||
|
f := &failingAnalytics{}
|
||||||
|
db.Analytics = f
|
||||||
|
InvalidateOverview()
|
||||||
|
|
||||||
|
_, err := OverviewData(context.Background())
|
||||||
|
require.Error(t, err, "没有任何快照时应该把错误交给调用方")
|
||||||
|
|
||||||
|
// 冷却期内的请求直接失败,不再打库。
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, err := OverviewData(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, f.siteCalls, "冷却期内不该重复聚合")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewCooldownClearedOnSuccess 冷却只针对失败,成功后照常刷新。
|
||||||
|
func TestOverviewCooldownClearedOnSuccess(t *testing.T) {
|
||||||
|
prev := db.Analytics
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Analytics = prev
|
||||||
|
InvalidateOverview()
|
||||||
|
})
|
||||||
|
|
||||||
|
bad := &failingAnalytics{}
|
||||||
|
db.Analytics = bad
|
||||||
|
InvalidateOverview()
|
||||||
|
_, err := OverviewData(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// 数据库恢复
|
||||||
|
good := &fakeAnalytics{todayQ: 1, todayA: 1}
|
||||||
|
db.Analytics = good
|
||||||
|
// 手动把冷却时间挪到过去,模拟「过了 30 秒」
|
||||||
|
ovMu.Lock()
|
||||||
|
ovFailAt = time.Now().Add(-2 * ovFailCooldown)
|
||||||
|
ovMu.Unlock()
|
||||||
|
|
||||||
|
v, err := OverviewData(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 10, v.TotalQuestions)
|
||||||
|
require.Equal(t, 1, good.siteCalls)
|
||||||
|
|
||||||
|
// 成功后冷却状态要清掉,否则下次失败判断会用到旧时间
|
||||||
|
ovMu.Lock()
|
||||||
|
failAt, failErr := ovFailAt, ovFailErr
|
||||||
|
ovMu.Unlock()
|
||||||
|
require.True(t, failAt.IsZero(), "成功后必须清掉失败时间")
|
||||||
|
require.NoError(t, failErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTodayFailureStillThrottled 数据库挂掉时「今日」两项也不能每个请求都打两条 COUNT。
|
||||||
|
func TestTodayFailureStillThrottled(t *testing.T) {
|
||||||
|
prev := db.Analytics
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Analytics = prev
|
||||||
|
InvalidateOverview()
|
||||||
|
})
|
||||||
|
|
||||||
|
db.Analytics = &failingAnalytics{}
|
||||||
|
InvalidateOverview()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
q, a := todayNumbers(context.Background(), now, 7, 3)
|
||||||
|
require.EqualValues(t, 7, q, "失败时回退到调用方给的值")
|
||||||
|
require.EqualValues(t, 3, a)
|
||||||
|
|
||||||
|
// 紧接一次:命中失败缓存,不该再打库(打了也没法区分,直接看返回值仍是回退值)
|
||||||
|
q, a = todayNumbers(context.Background(), now.Add(time.Second), 0, 0)
|
||||||
|
require.EqualValues(t, 7, q, "冷却期内沿用上一次的结果,不再打库")
|
||||||
|
require.EqualValues(t, 3, a)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user