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 jaDict = 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": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"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": "最後の回答:",