fix(insights): 数据面板「每日回答数」按 answered_at 分组,而非 created_at

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 → 测试失败)。
This commit is contained in:
tamakyi
2026-10-29 18:52:29 +08:00
parent 87176516f3
commit c9b426acbb
2 changed files with 101 additions and 14 deletions
+26 -14
View File
@@ -29,20 +29,20 @@ type BoxAnalyticsStore interface {
// BoxDayCount 某一天的提问 / 回答计数。 // BoxDayCount 某一天的提问 / 回答计数。
type BoxDayCount struct { type BoxDayCount struct {
Day time.Time `gorm:"column:day"` Day time.Time `gorm:"column:day"`
Questions int64 `gorm:"column:questions"` Questions int64 `gorm:"column:questions"`
Answers int64 `gorm:"column:answers"` Answers int64 `gorm:"column:answers"`
} }
// BoxFunnel 箱主的提问处理漏斗与响应速度。 // BoxFunnel 箱主的提问处理漏斗与响应速度。
type BoxFunnel struct { type BoxFunnel struct {
Total int64 // 全部提问 Total int64 // 全部提问
Answered int64 // 已回答 Answered int64 // 已回答
ReadOnly int64 // 已读未答 ReadOnly int64 // 已读未答
Unread int64 // 未读未答 Unread int64 // 未读未答
Private int64 // 私密提问 Private int64 // 私密提问
Anonymous int64 // 匿名(未登录)提问 Anonymous int64 // 匿名(未登录)提问
Pinned int64 // 置顶 Pinned int64 // 置顶
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at, // 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
// 会有偏高,见 answered_at 的注释)。 // 会有偏高,见 answered_at 的注释)。
RepliedIn24h int64 RepliedIn24h int64
@@ -68,6 +68,18 @@ func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time
d := analyticsDialectFor(db.DB, from) d := analyticsDialectFor(db.DB, from)
from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1) 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 和 // 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和
// answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN, // 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") return nil, errors.Wrap(err, "aggregate box questions by day")
} }
if err := db.WithContext(ctx).Model(&Question{}). 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) < ?", Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?",
userID, from, to). userID, from, to).
Group(d.day). Group(answerDay).
Scan(&as).Error; err != nil { Scan(&as).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box answers by day") 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 return out, nil
} }
var speed struct { var speed struct {
In24h int64 `gorm:"column:in_24h"` In24h int64 `gorm:"column:in_24h"`
Avg *float64 `gorm:"column:avg_min"` Avg *float64 `gorm:"column:avg_min"`
} }
if err := db.WithContext(ctx).Model(&Question{}). 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, Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h,
+75
View File
@@ -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, "回答日应出现在趋势结果里")
}