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 → 测试失败)。
234 lines
8.2 KiB
Go
234 lines
8.2 KiB
Go
// 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"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/pkg/errors"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// BoxAnalyticsStore 是「箱主数据面板」里提问侧的聚合查询(访问侧见 BoxViewsStore)。
|
||
//
|
||
// 与 SiteAnalyticsStore 分开:那个是站方看的全站数据(不含任何单箱维度),
|
||
// 这个是箱主看自己的。两边的 SQL 都按 user_id 走,混在一个接口里会让
|
||
// 「全站聚合整天缓存」和「箱主实时看自己的」两种缓存策略纠缠在一起。
|
||
type BoxAnalyticsStore interface {
|
||
// BoxTrend 返回 [from, to] 内每天的提问数与回答数。只返回有数据的天,缺日由上层补零。
|
||
BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error)
|
||
// BoxHours 返回 since 之后该箱收到提问的 24 小时分布(下标 0..23)。
|
||
BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error)
|
||
// BoxFunnel 返回该箱的提问处理漏斗。
|
||
BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error)
|
||
}
|
||
|
||
// BoxDayCount 某一天的提问 / 回答计数。
|
||
type BoxDayCount struct {
|
||
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 // 置顶
|
||
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
|
||
// 会有偏高,见 answered_at 的注释)。
|
||
RepliedIn24h int64
|
||
AvgReplyHour float64
|
||
// LastQuestionAt / LastAnswerAt 用于「你多久没看提问箱了」这类提示。
|
||
LastQuestionAt *time.Time
|
||
LastAnswerAt *time.Time
|
||
}
|
||
|
||
var BoxAnalytics BoxAnalyticsStore
|
||
|
||
var _ BoxAnalyticsStore = (*boxAnalytics)(nil)
|
||
|
||
func NewBoxAnalyticsStore(db *gorm.DB) BoxAnalyticsStore {
|
||
return &boxAnalytics{db}
|
||
}
|
||
|
||
type boxAnalytics struct {
|
||
*gorm.DB
|
||
}
|
||
|
||
func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error) {
|
||
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,
|
||
// 而两张结果集都很小,合并成本可以忽略。
|
||
type row struct {
|
||
Key string
|
||
N int64
|
||
}
|
||
|
||
var qs, as []row
|
||
if err := db.WithContext(ctx).Model(&Question{}).
|
||
Select(d.day+" AS key, COUNT(*) AS n").
|
||
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, from, to).
|
||
Group(d.day).
|
||
Scan(&qs).Error; err != nil {
|
||
return nil, errors.Wrap(err, "aggregate box questions by day")
|
||
}
|
||
if err := db.WithContext(ctx).Model(&Question{}).
|
||
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(answerDay).
|
||
Scan(&as).Error; err != nil {
|
||
return nil, errors.Wrap(err, "aggregate box answers by day")
|
||
}
|
||
|
||
// 用 map 合并,最后仍交给上层按日期补零(上层最清楚要展示哪些天)。
|
||
byDay := make(map[string]*BoxDayCount, len(qs))
|
||
order := make([]string, 0, len(qs)+len(as))
|
||
get := func(key string) *BoxDayCount {
|
||
if v, ok := byDay[key]; ok {
|
||
return v
|
||
}
|
||
t, err := time.ParseInLocation("2006-01-02", key, from.Location())
|
||
if err != nil {
|
||
t = from
|
||
}
|
||
v := &BoxDayCount{Day: t}
|
||
byDay[key] = v
|
||
order = append(order, key)
|
||
return v
|
||
}
|
||
for _, r := range qs {
|
||
get(r.Key).Questions = r.N
|
||
}
|
||
for _, r := range as {
|
||
get(r.Key).Answers = r.N
|
||
}
|
||
|
||
out := make([]BoxDayCount, 0, len(order))
|
||
for _, key := range order {
|
||
out = append(out, *byDay[key])
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (db *boxAnalytics) BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error) {
|
||
var out [24]int64
|
||
d := analyticsDialectFor(db.DB, since)
|
||
|
||
var rows []struct {
|
||
Hr int
|
||
N int64
|
||
}
|
||
if err := db.WithContext(ctx).Model(&Question{}).
|
||
Select(d.hour+" AS hr, COUNT(*) AS n").
|
||
Where("user_id = ? AND created_at >= ?", userID, since).
|
||
Group(d.hour).
|
||
Scan(&rows).Error; err != nil {
|
||
return out, errors.Wrap(err, "aggregate box question hours")
|
||
}
|
||
for _, r := range rows {
|
||
if r.Hr >= 0 && r.Hr < 24 {
|
||
out[r.Hr] = r.N
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error) {
|
||
out := &BoxFunnel{}
|
||
|
||
var totals struct {
|
||
Total int64 `gorm:"column:total"`
|
||
Answered int64 `gorm:"column:answered"`
|
||
ReadOnly int64 `gorm:"column:read_only"`
|
||
Unread int64 `gorm:"column:unread"`
|
||
Private int64 `gorm:"column:private"`
|
||
Anonymous int64 `gorm:"column:anonymous"`
|
||
Pinned int64 `gorm:"column:pinned"`
|
||
LastQ *time.Time `gorm:"column:last_q"`
|
||
LastA *time.Time `gorm:"column:last_a"`
|
||
}
|
||
if err := db.WithContext(ctx).Model(&Question{}).
|
||
Select(`COUNT(*) AS total,
|
||
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
|
||
COALESCE(SUM(CASE WHEN answer = '' AND is_read THEN 1 ELSE 0 END), 0) AS read_only,
|
||
COALESCE(SUM(CASE WHEN answer = '' AND NOT is_read THEN 1 ELSE 0 END), 0) AS unread,
|
||
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
|
||
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
|
||
COALESCE(SUM(CASE WHEN is_pinned THEN 1 ELSE 0 END), 0) AS pinned,
|
||
MAX(created_at) AS last_q,
|
||
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
|
||
Where("user_id = ?", userID).
|
||
Scan(&totals).Error; err != nil {
|
||
return nil, errors.Wrap(err, "aggregate box funnel")
|
||
}
|
||
out.Total = totals.Total
|
||
out.Answered = totals.Answered
|
||
out.ReadOnly = totals.ReadOnly
|
||
out.Unread = totals.Unread
|
||
out.Private = totals.Private
|
||
out.Anonymous = totals.Anonymous
|
||
out.Pinned = totals.Pinned
|
||
out.LastQuestionAt = totals.LastQ
|
||
out.LastAnswerAt = totals.LastA
|
||
|
||
// 响应速度:answered_at 为空的历史行退回 updated_at(口径偏大,只用于展示大致量级)。
|
||
replyMinutes := boxReplyMinutesExpr(db.DB, out.LastAnswerAt)
|
||
if replyMinutes == "" {
|
||
return out, nil
|
||
}
|
||
var speed struct {
|
||
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,
|
||
AVG(%[1]s) AS avg_min`, replyMinutes)).
|
||
Where("user_id = ? AND answer <> ''", userID).
|
||
Scan(&speed).Error; err != nil {
|
||
return nil, errors.Wrap(err, "aggregate box reply speed")
|
||
}
|
||
out.RepliedIn24h = speed.In24h
|
||
if speed.Avg != nil && *speed.Avg > 0 {
|
||
out.AvgReplyHour = *speed.Avg / 60
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// boxReplyMinutesExpr 返回「回答耗时(分钟)」的 SQL 表达式。
|
||
//
|
||
// 与 analyticsDialectFor 里的 replyMin 口径一致,但这里必须能固定到箱主自己那张表:
|
||
// 表达式里出现的列名都来自 questions 表,不需要额外限定。
|
||
func boxReplyMinutesExpr(db *gorm.DB, at *time.Time) string {
|
||
now := time.Now()
|
||
if at != nil {
|
||
now = *at
|
||
}
|
||
d := analyticsDialectFor(db, now)
|
||
return d.replyMin
|
||
}
|