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:
tamakyi
2026-10-29 18:25:53 +08:00
parent 5a05a61647
commit 4e574ac64a
19 changed files with 2255 additions and 2 deletions
+44
View File
@@ -1054,6 +1054,50 @@ var zhCNDict = map[string]string{
"stats.weekday.6": "周六",
"stats.weekday.7": "周日",
// 箱主数据面板(/user/insights)
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.funnelTotal": "全部提问",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已读未答",
"insights.funnelUnread": "未读未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。",
"insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。",
"insights.insight.conversion": "近 30 天的访客中,约有 %s%% 留下了提问。",
"insights.insight.bestDay": "最热闹的一天是 %s,有 %s 次浏览。",
"insights.insight.peakHour": "提问最集中在 %d 点前后。",
"insights.insight.answerRate": "你已经回答了 %s%% 的提问。",
"insights.insight.fastReply": "已回答的提问里,%s%% 是在 24 小时内回复的。",
"insights.insight.unread": "还有 %s 条提问没处理。",
"insights.insight.trendUp": "本周浏览比上周多了 %s%%。",
"insights.insight.trendDown": "本周浏览比上周少了 %s%%。",
"insights.insight.trendFlat": "本周浏览与上周差不多。",
"stats.foot.updated": "快照时间:",
"stats.foot.prevMonth": "最后一条提问:",
"stats.foot.prevAnswer": "最后一条回答:",