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:
@@ -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,
|
||||||
|
|||||||
@@ -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, "回答日应出现在趋势结果里")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user