From c9b426acbb6da25be3a92ae7de2c4192734ee3d7 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 29 Oct 2026 18:52:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(insights):=20=E6=95=B0=E6=8D=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E3=80=8C=E6=AF=8F=E6=97=A5=E5=9B=9E=E7=AD=94=E6=95=B0?= =?UTF-8?q?=E3=80=8D=E6=8C=89=20answered=5Fat=20=E5=88=86=E7=BB=84?= =?UTF-8?q?=EF=BC=8C=E8=80=8C=E9=9D=9E=20created=5Fat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoxTrend 的回答那条 SQL 复用了 d.day(基于 created_at 的日期表达式)做 GROUP BY, 但 WHERE 过滤用的是 answered_at。于是「40 天前创建、昨天才回答」的提问被排除在 窗口外(created_at < from),昨天的回答数里不出现;「今天创建、明天回答」的又 被记到今天。趋势图里的「每日回答数」根本不是「某天被回答了多少」。 单独构造 answerDay(基于 COALESCE(answered_at, updated_at)),WHERE / GROUP BY / SELECT 三者口径一致。PG 用 to_char(... AT TIME ZONE INTERVAL ...),MySQL 用 CONVERT_TZ,与 analyticsDialectFor 对 created_at 的处理对称。 新增 box_analytics_test.go:构造「40 天前创建、昨天回答」的提问,断言计入昨天, 并做变异验证(改回 d.day → 测试失败)。 --- internal/db/box_analytics.go | 40 +++++++++++------ internal/db/box_analytics_test.go | 75 +++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 internal/db/box_analytics_test.go diff --git a/internal/db/box_analytics.go b/internal/db/box_analytics.go index 5767197..4de3641 100644 --- a/internal/db/box_analytics.go +++ b/internal/db/box_analytics.go @@ -29,20 +29,20 @@ type BoxAnalyticsStore interface { // BoxDayCount 某一天的提问 / 回答计数。 type BoxDayCount struct { - Day time.Time `gorm:"column:day"` - Questions int64 `gorm:"column:questions"` - Answers int64 `gorm:"column:answers"` + Day time.Time `gorm:"column:day"` + Questions int64 `gorm:"column:questions"` + Answers int64 `gorm:"column:answers"` } // BoxFunnel 箱主的提问处理漏斗与响应速度。 type BoxFunnel struct { - Total int64 // 全部提问 - Answered int64 // 已回答 - ReadOnly int64 // 已读未答 - Unread int64 // 未读未答 - Private int64 // 私密提问 - Anonymous int64 // 匿名(未登录)提问 - Pinned int64 // 置顶 + Total int64 // 全部提问 + Answered int64 // 已回答 + ReadOnly int64 // 已读未答 + Unread int64 // 未读未答 + Private int64 // 私密提问 + Anonymous int64 // 匿名(未登录)提问 + Pinned int64 // 置顶 // 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at, // 会有偏高,见 answered_at 的注释)。 RepliedIn24h int64 @@ -68,6 +68,18 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time d := analyticsDialectFor(db.DB, from) from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1) + // 提问按 created_at 分组;回答必须按 answered_at(空则退回 updated_at)分组。 + // 两条 SQL 的 day 表达式不同:d.day 是 created_at 的日期,回答那条要基于 + // COALESCE(answered_at, updated_at),否则「40 天前创建、昨天才回答」的提问 + // 会被排除在窗口外,「今天创建、明天回答」的会被记到今天 —— 趋势图里的 + // 「每日回答数」就不是「某天被回答了多少」。 + answerDay := analyticsLocalize("COALESCE(answered_at, updated_at)", from) + if db.Dialector.Name() == "mysql" { + answerDay = fmt.Sprintf("DATE_FORMAT(CONVERT_TZ(COALESCE(answered_at, updated_at), '+00:00', '%s'), '%%Y-%%m-%%d')", analyticsTZOffset(from)) + } else { + answerDay = fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", answerDay) + } + // 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和 // answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN, // 而两张结果集都很小,合并成本可以忽略。 @@ -85,10 +97,10 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time return nil, errors.Wrap(err, "aggregate box questions by day") } if err := db.WithContext(ctx).Model(&Question{}). - Select(d.day+" AS key, COUNT(*) AS n"). + Select(answerDay+" AS key, COUNT(*) AS n"). Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?", userID, from, to). - Group(d.day). + Group(answerDay). Scan(&as).Error; err != nil { return nil, errors.Wrap(err, "aggregate box answers by day") } @@ -190,8 +202,8 @@ func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, return out, nil } var speed struct { - In24h int64 `gorm:"column:in_24h"` - Avg *float64 `gorm:"column:avg_min"` + In24h int64 `gorm:"column:in_24h"` + Avg *float64 `gorm:"column:avg_min"` } if err := db.WithContext(ctx).Model(&Question{}). Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h, diff --git a/internal/db/box_analytics_test.go b/internal/db/box_analytics_test.go new file mode 100644 index 0000000..2819942 --- /dev/null +++ b/internal/db/box_analytics_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 tamakyi. 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" +) + +// TestBoxTrendAnswerByAnsweredDate 验证「每日回答数」按 answered_at 分组, +// 而不是按 created_at。 +// +// 背景:BoxTrend 一度两条 SQL 都复用 created_at 的日期表达式,导致「40 天前创建、 +// 昨天才回答」的提问被排除在窗口外(created_at < from),昨天/今天的回答数里 +// 根本不出现;「今天创建、明天回答」的又会被记到今天。本测试构造一条 +// 「创建日远早于窗口、回答日落在窗口内」的提问,断言它出现在回答日那天。 +func TestBoxTrendAnswerByAnsweredDate(t *testing.T) { + db, cleanup := newTestDB(t) + ctx := context.Background() + store := NewBoxAnalyticsStore(db) + + t.Cleanup(func() { + _ = cleanup("questions") + }) + + now := time.Now() + // 窗口 = 最近 7 天 + from := now.AddDate(0, 0, -6) + to := now + + // 一条 40 天前创建、昨天才回答的提问 + answeredAt := now.AddDate(0, 0, -1) + createdAt := now.AddDate(0, 0, -40) + q := &Question{ + UserID: 9, + Content: "很久以前问的,昨天才回答", + Answer: "这是回答", + AnsweredAt: &answeredAt, + } + // 直接写 created_at / answered_at,绕过 Create 的自动时间戳 + require.NoError(t, db.Model(&Question{}).Create(q).Error) + require.NoError(t, db.Model(&Question{}).Where("id = ?", q.ID). + Updates(map[string]interface{}{ + "created_at": createdAt, + "answered_at": answeredAt, + "updated_at": answeredAt, + }).Error) + + trend, err := store.BoxTrend(ctx, 9, from, to) + require.NoError(t, err) + + // 找到回答日那天(昨天)的计数,答案数应 >= 1 + var found bool + for _, d := range trend { + if d.Day.Year() == answeredAt.Year() && + d.Day.Month() == answeredAt.Month() && + d.Day.Day() == answeredAt.Day() { + require.GreaterOrEqual(t, d.Answers, int64(1), + "回答应计入 answered_at 那一天,而不是按 created_at 被排除") + found = true + } + // 创建日(40 天前)不应出现在窗口内 + if d.Day.Year() == createdAt.Year() && + d.Day.Month() == createdAt.Month() && + d.Day.Day() == createdAt.Day() { + t.Errorf("创建日在窗口外,不应出现在趋势里") + } + } + require.True(t, found, "回答日应出现在趋势结果里") +}