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
@@ -1053,6 +1053,50 @@ var zhTWDict = 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": "最後一則回答:",