新增 /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/爬虫过滤。
234 lines
8.6 KiB
Go
234 lines
8.6 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"
|
||
"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
|
||
} |