Files
tamakyi 4e574ac64a 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/爬虫过滤。
2026-10-29 18:25:53 +08:00

132 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}