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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user