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:
@@ -0,0 +1,221 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBoxViews(t *testing.T) {
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
store := NewBoxViewsStore(db)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
test func(t *testing.T, ctx context.Context, store BoxViewsStore)
|
||||
}{
|
||||
{"BatchMergesSameKey", testBoxViewsBatchMergesSameKey},
|
||||
{"AccumulatesAcrossCalls", testBoxViewsAccumulatesAcrossCalls},
|
||||
{"DailyAndTotals", testBoxViewsDailyAndTotals},
|
||||
{"PurgeKeepsRecent", testBoxViewsPurgeKeepsRecent},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("box_views"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
tc.test(t, ctx, store)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 同一批里同 (user, day, visitor) 出现多次时,必须先在内存合并再写。
|
||||
//
|
||||
// PostgreSQL 的 ON CONFLICT DO UPDATE 不允许同一条 INSERT 两次命中同一行
|
||||
// ("ON CONFLICT DO UPDATE command cannot affect row a second time"),
|
||||
// 不合并的话记录器一次 flush 就会整批失败 —— 而 flush 里恰好可能包含
|
||||
// 同一访客在 2 秒内刷新三次的记录。
|
||||
func testBoxViewsBatchMergesSameKey(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
now := time.Date(2026, 10, 29, 15, 4, 5, 0, time.Local)
|
||||
|
||||
// 同一访客三次 + 另一个访客一次,全在同一批里
|
||||
err := store.RecordBatch(ctx, []RecordBoxViewOptions{
|
||||
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
|
||||
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
|
||||
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
|
||||
{UserID: 7, ViewedOn: now, Visitor: "bbbb"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), pv, "三次重复访问应累加进 views")
|
||||
require.Equal(t, int64(2), uv, "同一访客当天只算一个 UV")
|
||||
}
|
||||
|
||||
func testBoxViewsAccumulatesAcrossCalls(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
day := time.Date(2026, 10, 28, 9, 0, 0, 0, time.Local)
|
||||
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "y"}))
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), pv)
|
||||
require.Equal(t, int64(2), uv)
|
||||
}
|
||||
|
||||
func testBoxViewsDailyAndTotals(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local)
|
||||
|
||||
// 20 日:2 访客共 3 次;21 日:1 访客 1 次;22 日:无记录
|
||||
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
|
||||
{UserID: 11, ViewedOn: base, Visitor: "a"},
|
||||
{UserID: 11, ViewedOn: base, Visitor: "a"},
|
||||
{UserID: 11, ViewedOn: base, Visitor: "b"},
|
||||
{UserID: 11, ViewedOn: base.AddDate(0, 0, 1), Visitor: "a"},
|
||||
}))
|
||||
// 另一个箱主的数据不能串进来
|
||||
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{
|
||||
UserID: 12, ViewedOn: base, Visitor: "a",
|
||||
}))
|
||||
|
||||
days, err := store.Daily(ctx, 11, base, base.AddDate(0, 0, 2))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, days, 2, "没有记录的那天不返回,由上层补零")
|
||||
|
||||
require.Equal(t, "2026-10-20", days[0].Day.Format("2006-01-02"))
|
||||
require.Equal(t, int64(3), days[0].PV)
|
||||
require.Equal(t, int64(2), days[0].UV)
|
||||
|
||||
require.Equal(t, "2026-10-21", days[1].Day.Format("2006-01-02"))
|
||||
require.Equal(t, int64(1), days[1].PV)
|
||||
require.Equal(t, int64(1), days[1].UV)
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 11)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), pv)
|
||||
require.Equal(t, int64(3), uv)
|
||||
|
||||
first, err := store.FirstDay(ctx, 11)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "2026-10-20", first.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
func testBoxViewsPurgeKeepsRecent(t *testing.T, ctx context.Context, store BoxViewsStore) {
|
||||
old := time.Now().AddDate(0, 0, -30)
|
||||
recent := time.Now()
|
||||
|
||||
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
|
||||
{UserID: 5, ViewedOn: old, Visitor: "a"},
|
||||
{UserID: 5, ViewedOn: recent, Visitor: "b"},
|
||||
}))
|
||||
|
||||
n, err := store.Purge(ctx, time.Now().AddDate(0, 0, -7))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), n)
|
||||
|
||||
pv, uv, err := store.Totals(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), pv, "只保留保留期内的行")
|
||||
require.Equal(t, int64(1), uv)
|
||||
}
|
||||
@@ -47,6 +47,8 @@ var AllTables = []interface{}{
|
||||
&HoneypotHit{},
|
||||
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
|
||||
&BanRecord{},
|
||||
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
|
||||
&BoxView{},
|
||||
}
|
||||
|
||||
func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
@@ -166,6 +168,8 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
Honeypots = NewHoneypotStore(db)
|
||||
BanRecords = NewBanRecordsStore(db)
|
||||
Analytics = NewSiteAnalyticsStore(db)
|
||||
BoxViews = NewBoxViewsStore(db)
|
||||
BoxAnalytics = NewBoxAnalyticsStore(db)
|
||||
|
||||
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
|
||||
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||
|
||||
Reference in New Issue
Block a user