新增 /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/爬虫过滤。
66 lines
2.3 KiB
Go
66 lines
2.3 KiB
Go
package context
|
|
|
|
import "testing"
|
|
|
|
// 主题作用域:/user 后台必须命中(否则设置页、题目管理页拿不到自己的主题),
|
|
// 其余路径不得命中(提问箱页面由 question.Pager 按箱主注入,公开页保持站点默认)。
|
|
func TestIsThemeScope(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"/user": true,
|
|
"/user/": true,
|
|
"/user/profile": true,
|
|
"/user/questions": true,
|
|
"/user/votes/1/results": true,
|
|
"/username": false, // 前缀相近但不是用户后台
|
|
"/_/admin6": false,
|
|
"/_/admin6/share-card": false,
|
|
"/": false,
|
|
"/explore": false,
|
|
"/vote/abc": false,
|
|
"/api/v1/user/admin6": false,
|
|
"/api/v1/user": false,
|
|
"/deactivated": false,
|
|
}
|
|
|
|
for path, want := range cases {
|
|
if got := isThemeScope(path); got != want {
|
|
t.Errorf("isThemeScope(%q) = %v, want %v", path, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 页面作用域:主题按页面分别摆装饰靠这个值区分。
|
|
// 重点是把「箱主页 / 单题页 / 题目管理页」分清楚 —— 三者路径前缀很像。
|
|
func TestPageScope(t *testing.T) {
|
|
cases := map[string]string{
|
|
"/": "home",
|
|
"/explore": "explore",
|
|
"/explore/users": "explore",
|
|
"/login": "auth",
|
|
"/register": "auth",
|
|
"/user": "profile",
|
|
"/user/profile": "profile",
|
|
"/user/questions": "admin", // 题目列表:信息密,装饰要退
|
|
"/user/votes/1/results": "admin",
|
|
"/user/insights": "admin", // 数据面板同属「读数据」
|
|
"/_/admin6": "box",
|
|
"/_/admin6/": "box",
|
|
"/_/admin6/tag/杂谈": "box",
|
|
"/_/admin6/answer": "box", // 非数字段 → 不是单题页
|
|
"/_/admin6/123": "question",
|
|
"/_/admin6/123/": "question",
|
|
"/vote/abc123": "vote",
|
|
"/username": "",
|
|
"/_": "",
|
|
"/deactivated": "",
|
|
"/api/v1/user/admin6": "",
|
|
"/api/v1/user/admin6/questions": "",
|
|
}
|
|
|
|
for path, want := range cases {
|
|
if got := pageScope(path); got != want {
|
|
t.Errorf("pageScope(%q) = %q, want %q", path, got, want)
|
|
}
|
|
}
|
|
}
|