// 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 }