Files
tamabox/internal/db/box_analytics.go
T
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00

222 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}