// 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) // 提问与回答分两条 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(d.day+" 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). 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 }