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/爬虫过滤。
This commit is contained in:
tamakyi
2026-10-29 18:25:53 +08:00
parent 5a05a61647
commit 4e574ac64a
19 changed files with 2255 additions and 2 deletions
+234
View File
@@ -0,0 +1,234 @@
// 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"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BoxViewsStore 记录「提问箱主页被访问了多少次」。
//
// 为什么要自带一张表:全站数据页(internal/stats)只统计提问/回答/用户,
// 完全没有「有人来看过我的箱」这个维度 —— 箱主看不到自己的任何流量。
// 曾经用过外部计数服务(busuanzi),但它把统计放在第三方、且只能显示两个数,
// 既不可自控也无法按天看趋势,已移除。
//
// 口径与隐私:
// - 一行 = 一个访客在某一天访问了某个箱(唯一索引强制),`views` 是当天的次数;
// - PV = SUM(views),UV = 行数(因为每行已按访客去重);
// - 访客标识是 **带日盐的 IP HMAC**,不是 IP 本身,也无法跨天关联同一人。
// 代价是「近 7 天 UV」= 各日 UV 之和,同一人多天来访会被重复计入 —— 这是
// 隐私与准确性的取舍,页面上按「按日去重的独立访客」表述。
type BoxViewsStore interface {
// Record 累加一次访问。实现为单条 UPSERT,并发安全。
Record(ctx context.Context, opts RecordBoxViewOptions) error
// RecordBatch 批量累加(记录器的 flush 用)。单条 SQL,避免 N 次往返。
RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error
// Daily 返回 [from, to] 内每天的 PV / UV,日期按天升序,缺日不补(由上层补零)。
Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error)
// TopDays 取历史上 PV 最高的若干天,供「你的最好一天」这类结论使用。
TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error)
// FirstDay 返回最早有记录的一天;无记录时返回零值时间。
FirstDay(ctx context.Context, userID uint) (time.Time, error)
// Totals 返回全期累计访问量(PV / UV)。
Totals(ctx context.Context, userID uint) (pv, uv int64, err error)
// Purge 删除 before 之前的明细,控制表体积。统计窗口最长 30 天,
// 但「历史最好一天」要看全部,默认保留期由调用方决定(见 insights.Retention)。
Purge(ctx context.Context, before time.Time) (int64, error)
}
// BoxView 一次「访客 × 天 × 箱」的访问记录。
//
// 刻意不内嵌 dbutil.Model:统计流水不需要软删除,而软删除会让唯一索引
// (user_id, viewed_on, visitor) 失效 —— 被「删掉」的行仍然占着槽位,
// 同一访客当天再也插不进去(GORM 软删除 + 唯一索引的经典坑)。
type BoxView struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"uniqueIndex:idx_box_views_day_visitor,priority:1;index:idx_box_views_user_day,priority:1"`
ViewedOn time.Time `gorm:"type:date;uniqueIndex:idx_box_views_day_visitor,priority:2;index:idx_box_views_user_day,priority:2"`
Visitor string `gorm:"size:32;uniqueIndex:idx_box_views_day_visitor,priority:3"`
Views int64 `gorm:"not null;default:1"`
CreatedAt time.Time
UpdatedAt time.Time
}
// BoxViewDay 某一天聚合后的访问量。
type BoxViewDay struct {
// Day 当天 0 点(调用方传入的时区)。扫描出来是 date 列,loc 由数据库会话决定,
// 上层只取 Year/Month/Day 拼 key,不依赖时分秒。
Day time.Time `gorm:"column:viewed_on"`
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
type RecordBoxViewOptions struct {
UserID uint
ViewedOn time.Time // 只有年月日有意义,写入前会被截断到当天 0 点
Visitor string
}
var BoxViews BoxViewsStore
var _ BoxViewsStore = (*boxViews)(nil)
func NewBoxViewsStore(db *gorm.DB) BoxViewsStore {
return &boxViews{db}
}
type boxViews struct {
*gorm.DB
}
// dayOnly 把时间截断到当天 0 点。用传入时间的时区,不做 UTC 归一 ——
// 站点按本地时间切「今天」,跨时区归一会让日报表偏一天。
func dayOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
func (db *boxViews) Record(ctx context.Context, opts RecordBoxViewOptions) error {
return db.RecordBatch(ctx, []RecordBoxViewOptions{opts})
}
// RecordBatch 单条 INSERT ... ON CONFLICT DO UPDATE 累加。
//
// 冲突时只把 views 加一,visited_at 之外的列不动:同一访客当天第二次访问
// 只是「同一行还来了」,UV 不变、PV +1。
func (db *boxViews) RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error {
if len(opts) == 0 {
return nil
}
now := time.Now()
rows := make([]BoxView, 0, len(opts))
for _, o := range opts {
if o.UserID == 0 || o.Visitor == "" {
// 缺用户或访客标识的行没有统计价值,直接丢弃:
// 空 Visitor 会让同一箱所有匿名访问挤进同一行,把 UV 压成 1。
continue
}
rows = append(rows, BoxView{
UserID: o.UserID,
ViewedOn: dayOnly(o.ViewedOn),
Visitor: o.Visitor,
Views: 1,
CreatedAt: now,
UpdatedAt: now,
})
}
if len(rows) == 0 {
return nil
}
// 同一批里可能出现同一个 (user, day, visitor) 多次(并发请求攒到一次 flush):
// PostgreSQL 的 ON CONFLICT 不支持在同一条 INSERT 里两次命中同一行
// ("ON CONFLICT DO UPDATE command cannot affect row a second time")。
// 所以先在内存里按唯一键合并,把次数累加到 Views 上。
merged := make([]BoxView, 0, len(rows))
index := make(map[[3]interface{}]int, len(rows))
for _, r := range rows {
key := [3]interface{}{r.UserID, r.ViewedOn.Format("2006-01-02"), r.Visitor}
if i, ok := index[key]; ok {
merged[i].Views += r.Views
continue
}
index[key] = len(merged)
merged = append(merged, r)
}
err := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "viewed_on"}, {Name: "visitor"}},
DoUpdates: clause.Assignments(map[string]interface{}{
// 表名限定:PG 里裸 views 在 INSERT ... ON CONFLICT 的 SET 子句里
// 可能被当成目标列的别名解析,加上表名前缀最稳。
"views": gorm.Expr("box_views.views + excluded.views"),
"updated_at": now,
}),
}).Create(&merged).Error
if err != nil {
return errors.Wrap(err, "upsert box views")
}
return nil
}
func (db *boxViews) Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) {
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ? AND viewed_on >= ? AND viewed_on <= ?", userID, dayOnly(from), dayOnly(to)).
Group("viewed_on").
Order("viewed_on").
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "aggregate box view days")
}
return days, nil
}
func (db *boxViews) TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) {
if limit <= 0 {
limit = 1
}
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Group("viewed_on").
Order("pv DESC").
Limit(limit).
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "top box view days")
}
return days, nil
}
func (db *boxViews) FirstDay(ctx context.Context, userID uint) (time.Time, error) {
var day *time.Time
err := db.WithContext(ctx).Model(&BoxView{}).
Select("MIN(viewed_on)").
Where("user_id = ?", userID).
Scan(&day).Error
if err != nil {
return time.Time{}, errors.Wrap(err, "first box view day")
}
if day == nil {
return time.Time{}, nil
}
return *day, nil
}
func (db *boxViews) Totals(ctx context.Context, userID uint) (int64, int64, error) {
var row struct {
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
err := db.WithContext(ctx).Model(&BoxView{}).
Select("COALESCE(SUM(views), 0) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Scan(&row).Error
if err != nil {
return 0, 0, errors.Wrap(err, "total box views")
}
return row.PV, row.UV, nil
}
// Purge 删除 before 之前的访问明细。返回删除行数。
//
// 用 Delete 而非 Unscoped 也没关系 —— BoxView 没有 DeletedAt 字段,
// GORM 看不到软删除标记,会直接发 DELETE。
func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error) {
res := db.WithContext(ctx).
Where("viewed_on < ?", dayOnly(before)).
Delete(&BoxView{})
if res.Error != nil {
return 0, errors.Wrap(res.Error, "purge box views")
}
return res.RowsAffected, nil
}