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:
@@ -0,0 +1,438 @@
|
||||
// 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 insights 为箱主生成「自己的数据面板」。
|
||||
//
|
||||
// 与 internal/stats 的分工:stats 是全站公开页(任何人都能看、不含单箱维度、
|
||||
// 结果按自然日缓存);本包只看当前登录用户自己的箱,数据实时,只对箱主可见。
|
||||
package insights
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
const (
|
||||
// TrendDays 趋势窗口天数(含今天)。
|
||||
TrendDays = 30
|
||||
// WeekDays「本周」窗口与「上周」对比窗口的长度。
|
||||
WeekDays = 7
|
||||
)
|
||||
|
||||
// Page 是箱主数据面板的渲染模型。
|
||||
type Page struct {
|
||||
Lang string
|
||||
|
||||
// HasViews 表示是否已经有任何一条访问记录。新箱子的面板会显示引导文案,
|
||||
// 而不是一排 0 —— 后者看起来像统计坏了。
|
||||
HasViews bool
|
||||
Since time.Time
|
||||
|
||||
Today Window
|
||||
Week Window
|
||||
Month Window
|
||||
All Window
|
||||
|
||||
Days []Day
|
||||
// Hours 24 小时来访分布(近 30 天的浏览)。
|
||||
Hours [24]Hour
|
||||
PeakHour int
|
||||
PeakHourN int64
|
||||
|
||||
Funnel *db.BoxFunnel
|
||||
|
||||
// 派生指标(百分比,0~100)。
|
||||
Conversion float64 // 近 30 天「提问数 / UV」
|
||||
AnswerRate float64 // 全期回答率
|
||||
FastReply float64 // 全期 24 小时内回答占比
|
||||
|
||||
BestDay *Day
|
||||
// AnswerRateText 是 AnswerRate 的展示形式:整数不带小数点(100%,而不是 100.0%)。
|
||||
AnswerRateText string
|
||||
// Conclusions 自动生成的一两句话结论,已本地化。
|
||||
Conclusions []string
|
||||
}
|
||||
|
||||
// Window 一段时间内的汇总。
|
||||
type Window struct {
|
||||
PV int64
|
||||
UV int64
|
||||
Questions int64
|
||||
Answers int64
|
||||
// PrevPV / HasPrev 用于跟上一个等长周期比较(只有 Week 有值)。
|
||||
PrevPV int64
|
||||
HasPrev bool
|
||||
// Delta 相对 PrevPV 的变化百分比。PrevPV 为 0 时无意义,用 HasDelta 标记。
|
||||
Delta float64
|
||||
HasDelta bool
|
||||
}
|
||||
|
||||
// Day 趋势图里的一天。
|
||||
type Day struct {
|
||||
Key string // 2026-10-29
|
||||
Label string // 10-29
|
||||
Weekday string
|
||||
PV int64
|
||||
UV int64
|
||||
Questions int64
|
||||
Answers int64
|
||||
// 高度百分比(0~100),直接当 CSS 高度用。
|
||||
PVHeight float64
|
||||
UVHeight float64
|
||||
QHeight float64
|
||||
IsToday bool
|
||||
IsEmpty bool
|
||||
}
|
||||
|
||||
// Hour 时段分布里的一个柱子。
|
||||
type Hour struct {
|
||||
Hour int
|
||||
N int64
|
||||
Height float64
|
||||
}
|
||||
|
||||
// Build 装配箱主 userID 的数据面板。
|
||||
//
|
||||
// 任何一个子查询失败都只让对应区块缺数据,不返回错误:面板是「看个大概」的页面,
|
||||
// 因为一条聚合 SQL 挂了就整页 500,对箱主没有任何帮助。
|
||||
func Build(ctx context.Context, lang string, userID uint, now time.Time) *Page {
|
||||
p := &Page{Lang: lang, Funnel: &db.BoxFunnel{}}
|
||||
|
||||
today := dayOnly(now)
|
||||
monthFrom := today.AddDate(0, 0, -(TrendDays - 1))
|
||||
weekFrom := today.AddDate(0, 0, -(WeekDays - 1))
|
||||
prevWeekFrom := today.AddDate(0, 0, -(2*WeekDays - 1))
|
||||
prevWeekTo := today.AddDate(0, 0, -WeekDays)
|
||||
|
||||
// 先无条件铺好 30 天空骨架:访问统计和提问统计任何一侧缺数据,
|
||||
// 趋势图都还能画出坐标轴与空柱,而不是整块消失。
|
||||
p.buildDays(nil, today)
|
||||
|
||||
if db.BoxViews != nil {
|
||||
days, err := db.BoxViews.Daily(ctx, userID, monthFrom, today)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("加载箱主访问趋势失败")
|
||||
} else {
|
||||
p.fillViews(days)
|
||||
}
|
||||
|
||||
if pv, uv, err := db.BoxViews.Totals(ctx, userID); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("加载箱主累计访问失败")
|
||||
} else {
|
||||
p.All.PV, p.All.UV = pv, uv
|
||||
p.HasViews = pv > 0
|
||||
}
|
||||
|
||||
if since, err := db.BoxViews.FirstDay(ctx, userID); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("加载箱主首次访问时间失败")
|
||||
} else {
|
||||
p.Since = since
|
||||
}
|
||||
}
|
||||
|
||||
if db.BoxAnalytics != nil {
|
||||
if trend, err := db.BoxAnalytics.BoxTrend(ctx, userID, monthFrom, today); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问趋势失败")
|
||||
} else {
|
||||
p.fillQuestions(trend)
|
||||
}
|
||||
|
||||
if hours, err := db.BoxAnalytics.BoxHours(ctx, userID, monthFrom); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("加载箱主时段分布失败")
|
||||
} else {
|
||||
p.buildHours(hours)
|
||||
}
|
||||
|
||||
if funnel, err := db.BoxAnalytics.BoxFunnel(ctx, userID); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问漏斗失败")
|
||||
} else {
|
||||
p.Funnel = funnel
|
||||
}
|
||||
}
|
||||
|
||||
p.aggregateWindows(today, weekFrom, prevWeekFrom, prevWeekTo)
|
||||
p.computeRates()
|
||||
return p
|
||||
}
|
||||
|
||||
// dayOnly 截断到当天 0 点,保留传入时区。
|
||||
func dayOnly(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// buildDays 铺出完整的 30 天序列(不含任何计数)。
|
||||
func (p *Page) buildDays(_ []db.BoxViewDay, today time.Time) {
|
||||
from := today.AddDate(0, 0, -(TrendDays - 1))
|
||||
p.Days = make([]Day, 0, TrendDays)
|
||||
for i := 0; i < TrendDays; i++ {
|
||||
d := from.AddDate(0, 0, i)
|
||||
p.Days = append(p.Days, Day{
|
||||
Key: d.Format("2006-01-02"),
|
||||
Label: d.Format("01-02"),
|
||||
Weekday: fmt.Sprintf("stats.wd.%d", int(d.Weekday())),
|
||||
IsToday: sameDay(d, today),
|
||||
IsEmpty: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fillViews 把访问量填进已有骨架。
|
||||
func (p *Page) fillViews(rows []db.BoxViewDay) {
|
||||
index := make(map[string]int, len(p.Days))
|
||||
for i, d := range p.Days {
|
||||
index[d.Key] = i
|
||||
}
|
||||
for _, r := range rows {
|
||||
if i, ok := index[r.Day.Format("2006-01-02")]; ok {
|
||||
p.Days[i].PV = r.PV
|
||||
p.Days[i].UV = r.UV
|
||||
p.Days[i].IsEmpty = r.PV == 0 && p.Days[i].Questions == 0
|
||||
}
|
||||
}
|
||||
p.recomputeHeights()
|
||||
}
|
||||
|
||||
// fillQuestions 把提问 / 回答数填进已有骨架。
|
||||
func (p *Page) fillQuestions(rows []db.BoxDayCount) {
|
||||
index := make(map[string]int, len(p.Days))
|
||||
for i, d := range p.Days {
|
||||
index[d.Key] = i
|
||||
}
|
||||
for _, r := range rows {
|
||||
if i, ok := index[r.Day.Format("2006-01-02")]; ok {
|
||||
p.Days[i].Questions = r.Questions
|
||||
p.Days[i].Answers = r.Answers
|
||||
p.Days[i].IsEmpty = p.Days[i].PV == 0 && r.Questions == 0
|
||||
}
|
||||
}
|
||||
p.recomputeHeights()
|
||||
}
|
||||
|
||||
// recomputeHeights 按窗口内最大值归一化成百分比高度。
|
||||
//
|
||||
// 最大值为 0 时全部置 0(而不是除零),模板会把柱高 0 渲染成一条淡淡的底线。
|
||||
func (p *Page) recomputeHeights() {
|
||||
var maxPV, maxQ int64
|
||||
for _, d := range p.Days {
|
||||
if d.PV > maxPV {
|
||||
maxPV = d.PV
|
||||
}
|
||||
if d.UV > maxPV {
|
||||
maxPV = d.UV
|
||||
}
|
||||
if d.Questions > maxQ {
|
||||
maxQ = d.Questions
|
||||
}
|
||||
}
|
||||
for i := range p.Days {
|
||||
d := &p.Days[i]
|
||||
if maxPV > 0 {
|
||||
d.PVHeight = pct(d.PV, maxPV)
|
||||
d.UVHeight = pct(d.UV, maxPV)
|
||||
}
|
||||
if maxQ > 0 {
|
||||
d.QHeight = pct(d.Questions, maxQ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Page) buildHours(counts [24]int64) {
|
||||
var max int64
|
||||
for _, n := range counts {
|
||||
if n > max {
|
||||
max = n
|
||||
}
|
||||
}
|
||||
for h := 0; h < 24; h++ {
|
||||
p.Hours[h] = Hour{Hour: h, N: counts[h]}
|
||||
if max > 0 {
|
||||
p.Hours[h].Height = pct(counts[h], max)
|
||||
}
|
||||
if counts[h] > p.PeakHourN {
|
||||
p.PeakHourN = counts[h]
|
||||
p.PeakHour = h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateWindows 从日序列切出今日 / 本周 / 本月窗口,并算上周对比。
|
||||
func (p *Page) aggregateWindows(today, weekFrom, prevFrom, prevTo time.Time) {
|
||||
for _, d := range p.Days {
|
||||
t, err := time.ParseInLocation("2006-01-02", d.Key, today.Location())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p.Month.PV += d.PV
|
||||
p.Month.UV += d.UV
|
||||
p.Month.Questions += d.Questions
|
||||
p.Month.Answers += d.Answers
|
||||
|
||||
if !t.Before(weekFrom) {
|
||||
p.Week.PV += d.PV
|
||||
p.Week.UV += d.UV
|
||||
p.Week.Questions += d.Questions
|
||||
p.Week.Answers += d.Answers
|
||||
}
|
||||
if !t.Before(prevFrom) && !t.After(prevTo) {
|
||||
p.Week.PrevPV += d.PV
|
||||
}
|
||||
if sameDay(t, today) {
|
||||
p.Today.PV = d.PV
|
||||
p.Today.UV = d.UV
|
||||
p.Today.Questions = d.Questions
|
||||
p.Today.Answers = d.Answers
|
||||
}
|
||||
|
||||
if p.BestDay == nil || d.PV > p.BestDay.PV {
|
||||
cp := d
|
||||
p.BestDay = &cp
|
||||
}
|
||||
}
|
||||
|
||||
// 全期提问 / 回答来自漏斗(它扫的是整张表,不受 30 天窗口限制)。
|
||||
if p.Funnel != nil {
|
||||
p.All.Questions = p.Funnel.Total
|
||||
p.All.Answers = p.Funnel.Answered
|
||||
}
|
||||
|
||||
p.Week.HasPrev = true
|
||||
if p.Week.PrevPV > 0 {
|
||||
p.Week.HasDelta = true
|
||||
p.Week.Delta = float64(p.Week.PV-p.Week.PrevPV) / float64(p.Week.PrevPV) * 100
|
||||
}
|
||||
// 上周一次访问都没有时,「涨了 100%」这类结论毫无意义 —— 直接不显示对比。
|
||||
}
|
||||
|
||||
func (p *Page) computeRates() {
|
||||
if p.Month.UV > 0 {
|
||||
p.Conversion = float64(p.Month.Questions) / float64(p.Month.UV) * 100
|
||||
}
|
||||
if p.Funnel != nil && p.Funnel.Total > 0 {
|
||||
p.AnswerRate = float64(p.Funnel.Answered) / float64(p.Funnel.Total) * 100
|
||||
if p.Funnel.Answered > 0 {
|
||||
p.FastReply = float64(p.Funnel.RepliedIn24h) / float64(p.Funnel.Answered) * 100
|
||||
}
|
||||
}
|
||||
p.AnswerRateText = formatPct(p.AnswerRate)
|
||||
}
|
||||
|
||||
// formatPct 把百分比渲染成「100%」或「33.3%」——整数值不补 .0。
|
||||
//
|
||||
// 面板上「100.0%」这种写法会让人下意识去找那个小数位,而它并不携带信息。
|
||||
func formatPct(v float64) string {
|
||||
if v == math.Trunc(v) {
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f", v)
|
||||
}
|
||||
|
||||
// pct 返回 n 相对 max 的百分比,保留一位小数。
|
||||
func pct(n, max int64) float64 {
|
||||
if max <= 0 {
|
||||
return 0
|
||||
}
|
||||
v := float64(n) / float64(max) * 100
|
||||
// 四舍五入到 0.1,避免出现一长串小数把 CSS 撑得很长。
|
||||
return float64(int(v*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func sameDay(a, b time.Time) bool {
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
// formatDay 把日期格式化成「10-29」这种短标签,供文案使用。
|
||||
func formatDay(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("01-02")
|
||||
}
|
||||
|
||||
// FormatInsights 生成面板顶部的自动结论。
|
||||
//
|
||||
// 放在这里而不是模板里:结论需要做「有没有数据」「除零」这类判断,
|
||||
// 全塞进模板会让它变成一堆 eq/if 的嵌套。
|
||||
func (p *Page) FormatInsights() {
|
||||
lang := p.Lang
|
||||
add := func(key string, args ...interface{}) {
|
||||
text := i18n.T(lang, key, args...)
|
||||
// 缺词条时 T 会返回 key 本身,别把内部 key 印到页面上。
|
||||
if text == "" || text == key {
|
||||
return
|
||||
}
|
||||
p.Conclusions = append(p.Conclusions, text)
|
||||
}
|
||||
|
||||
if !p.HasViews && p.Funnel.Total == 0 {
|
||||
add("insights.insight.empty")
|
||||
return
|
||||
}
|
||||
|
||||
if p.HasViews {
|
||||
add("insights.insight.weekViews", formatInt(p.Week.PV), formatInt(p.Week.UV))
|
||||
}
|
||||
|
||||
// 样本太小的时候「转化率」没有解释力:1 位访客提了 1 个问题就是 100%,
|
||||
// 这种数字只会误导箱主,不如不说。
|
||||
if p.Month.UV >= 5 && p.Month.Questions > 0 {
|
||||
add("insights.insight.conversion", formatPct(p.Conversion))
|
||||
}
|
||||
|
||||
if p.BestDay != nil && p.BestDay.PV > 0 {
|
||||
add("insights.insight.bestDay", p.BestDay.Label, formatInt(p.BestDay.PV))
|
||||
}
|
||||
|
||||
if p.PeakHourN > 0 {
|
||||
add("insights.insight.peakHour", p.PeakHour)
|
||||
}
|
||||
|
||||
if p.Funnel.Total > 0 {
|
||||
add("insights.insight.answerRate", formatPct(p.AnswerRate))
|
||||
}
|
||||
if p.FastReply > 0 {
|
||||
add("insights.insight.fastReply", fmt.Sprintf("%.0f", p.FastReply))
|
||||
}
|
||||
if p.Funnel.Unread > 0 {
|
||||
add("insights.insight.unread", formatInt(p.Funnel.Unread))
|
||||
}
|
||||
|
||||
if p.Week.HasDelta {
|
||||
switch {
|
||||
case p.Week.Delta >= 5:
|
||||
add("insights.insight.trendUp", fmt.Sprintf("%.0f", p.Week.Delta))
|
||||
case p.Week.Delta <= -5:
|
||||
add("insights.insight.trendDown", fmt.Sprintf("%.0f", -p.Week.Delta))
|
||||
default:
|
||||
add("insights.insight.trendFlat")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatInt 给数字加千分位。
|
||||
func formatInt(n int64) string {
|
||||
if n < 0 {
|
||||
return "-" + formatInt(-n)
|
||||
}
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// 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 insights
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPct(t *testing.T) {
|
||||
cases := []struct {
|
||||
n, max int64
|
||||
want float64
|
||||
}{
|
||||
{0, 0, 0}, // 除零:不能算出 NaN 塞进 CSS
|
||||
{5, 0, 0}, // 同上
|
||||
{10, 10, 100}, // 满格
|
||||
{1, 3, 33.3}, // 四舍五入到一位小数
|
||||
{2, 3, 66.7},
|
||||
{1, 1000, 0.1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pct(c.n, c.max); got != c.want {
|
||||
t.Errorf("pct(%d, %d) = %v, want %v", c.n, c.max, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatInt(t *testing.T) {
|
||||
cases := map[int64]string{
|
||||
0: "0",
|
||||
7: "7",
|
||||
999: "999",
|
||||
1000: "1,000",
|
||||
12345: "12,345",
|
||||
1234567: "1,234,567",
|
||||
-1234: "-1,234",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := formatInt(in); got != want {
|
||||
t.Errorf("formatInt(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口切分是「今日 / 近 7 天 / 近 30 天」与「本周 vs 上周」的唯一实现,
|
||||
// 算错不会报错、只会安静地显示错误的数字,所以单独测。
|
||||
func TestAggregateWindows(t *testing.T) {
|
||||
loc := time.Local
|
||||
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
|
||||
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
|
||||
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
|
||||
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
|
||||
|
||||
p := &Page{}
|
||||
for i := 0; i < TrendDays; i++ {
|
||||
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
|
||||
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
|
||||
}
|
||||
set := func(offsetDays int, pv, uv, q, a int64) {
|
||||
key := today.AddDate(0, 0, offsetDays).Format("2006-01-02")
|
||||
for i := range p.Days {
|
||||
if p.Days[i].Key == key {
|
||||
p.Days[i].PV, p.Days[i].UV = pv, uv
|
||||
p.Days[i].Questions, p.Days[i].Answers = q, a
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("日期 %s 不在骨架里", key)
|
||||
}
|
||||
|
||||
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
|
||||
set(-1, 2, 2, 0, 1) // 昨天:本周
|
||||
set(-6, 4, 1, 2, 0) // 10-23:本周起点
|
||||
set(-7, 9, 6, 0, 0) // 10-22:上周末日
|
||||
set(-13, 6, 5, 0, 0) // 10-16:上周起点
|
||||
|
||||
p.aggregateWindows(today, weekFrom, prevFrom, prevTo)
|
||||
|
||||
// 本周 = 5+2+4;上周 = 9+6
|
||||
if p.Week.PV != 11 {
|
||||
t.Errorf("Week.PV = %d, want 11", p.Week.PV)
|
||||
}
|
||||
if p.Week.PrevPV != 15 {
|
||||
t.Errorf("Week.PrevPV = %d, want 15", p.Week.PrevPV)
|
||||
}
|
||||
if !p.Week.HasDelta {
|
||||
t.Fatal("上周有数据时 HasDelta 应为 true")
|
||||
}
|
||||
if got, want := p.Week.Delta, float64(11-15)/15*100; got != want {
|
||||
t.Errorf("Week.Delta = %v, want %v", got, want)
|
||||
}
|
||||
if p.Today.PV != 5 || p.Today.UV != 3 {
|
||||
t.Errorf("Today = %+v, want PV 5 UV 3", p.Today)
|
||||
}
|
||||
if p.Month.PV != 26 {
|
||||
t.Errorf("Month.PV = %d, want 26(全部 5 天之和)", p.Month.PV)
|
||||
}
|
||||
if p.BestDay == nil || p.BestDay.PV != 9 {
|
||||
t.Errorf("BestDay = %+v, want 10-22 的 9 次", p.BestDay)
|
||||
}
|
||||
}
|
||||
|
||||
// 上周一次访问都没有时不能给出「涨了 100%」这类结论。
|
||||
func TestAggregateWindowsNoPrev(t *testing.T) {
|
||||
loc := time.Local
|
||||
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
|
||||
|
||||
p := &Page{}
|
||||
for i := 0; i < TrendDays; i++ {
|
||||
d := today.AddDate(0, 0, -(TrendDays - 1) + i)
|
||||
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
|
||||
}
|
||||
p.Days[len(p.Days)-1].PV = 3
|
||||
|
||||
p.aggregateWindows(today, today.AddDate(0, 0, -6), today.AddDate(0, 0, -13), today.AddDate(0, 0, -7))
|
||||
|
||||
if p.Week.HasDelta {
|
||||
t.Error("上周 PV 为 0 时 HasDelta 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameDay(t *testing.T) {
|
||||
a := time.Date(2026, 10, 29, 0, 0, 0, 0, time.Local)
|
||||
b := time.Date(2026, 10, 29, 23, 59, 59, 0, time.Local)
|
||||
c := time.Date(2026, 10, 30, 0, 0, 1, 0, time.Local)
|
||||
if !sameDay(a, b) {
|
||||
t.Error("同一天的任意时刻应判为同一天")
|
||||
}
|
||||
if sameDay(a, c) {
|
||||
t.Error("跨天应判为不同天")
|
||||
}
|
||||
}
|
||||
|
||||
// 访客标识必须「同日可去重、跨日不可关联」:这是面板隐私承诺的技术基础。
|
||||
func TestVisitorID(t *testing.T) {
|
||||
r := NewRecorder(nil, "test-secret") // 不启动写入,仅用哈希方法
|
||||
defer r.Close()
|
||||
|
||||
day1 := time.Date(2026, 10, 29, 8, 0, 0, 0, time.Local)
|
||||
day1Later := time.Date(2026, 10, 29, 22, 0, 0, 0, time.Local)
|
||||
day2 := time.Date(2026, 10, 30, 8, 0, 0, 0, time.Local)
|
||||
|
||||
a := r.visitorID("1.2.3.4", day1)
|
||||
if a != r.visitorID("1.2.3.4", day1Later) {
|
||||
t.Error("同一天同一 IP 必须得到相同标识,否则 UV 去重失效")
|
||||
}
|
||||
if a == r.visitorID("1.2.3.4", day2) {
|
||||
t.Error("跨天必须得到不同标识,否则可长期追踪同一访客")
|
||||
}
|
||||
if a == r.visitorID("5.6.7.8", day1) {
|
||||
t.Error("不同 IP 同一天必须不同")
|
||||
}
|
||||
if len(a) != 32 {
|
||||
t.Errorf("标识长度 = %d, want 32", len(a))
|
||||
}
|
||||
// 换密钥后整体不可关联(运维轮换 xsrf_key 时历史哈希不会被重新对上)
|
||||
other := NewRecorder(nil, "another-secret")
|
||||
defer other.Close()
|
||||
if a == other.visitorID("1.2.3.4", day1) {
|
||||
t.Error("不同密钥应给出不同标识")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCrawler(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"": true, // 空 UA 基本是脚本
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false,
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true,
|
||||
"curl/8.4.0": true,
|
||||
"python-requests/2.31.0": true,
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0)": true,
|
||||
"facebookexternalhit/1.1": true,
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false,
|
||||
}
|
||||
for ua, want := range cases {
|
||||
if got := isCrawler(ua); got != want {
|
||||
t.Errorf("isCrawler(%q) = %v, want %v", ua, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrefetch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"普通请求", "", "", false},
|
||||
{"Chrome 预取", "Sec-Purpose", "prefetch", true},
|
||||
{"Chrome 预渲染", "Sec-Purpose", "prefetch;prerender", true},
|
||||
{"Safari 预览", "X-Purpose", "preview", true},
|
||||
{"Firefox 预取", "X-Moz", "prefetch", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := httptest.NewRequest("GET", "/_/someone", nil)
|
||||
if c.header != "" {
|
||||
req.Header.Set(c.header, c.value)
|
||||
}
|
||||
if got := isPrefetch(req); got != c.want {
|
||||
t.Errorf("%s: isPrefetch = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// 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 insights
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// Recorder 异步记录箱主页访问。
|
||||
//
|
||||
// 为什么不同步写库:箱主页是最高频的读路径,一次页面渲染里已经有 5~6 次查询
|
||||
// (用户、提问列表、回答数、三个功能开关)。再同步加一条 UPSERT,等于给每个
|
||||
// 访客多付一次写事务的代价 —— 而统计本来就不需要精确到毫秒,丢掉最后几秒的
|
||||
// 数据没有任何影响。所以这里是「尽力而为」:投递不阻塞,积压了就丢。
|
||||
//
|
||||
// 生命周期:Init 时创建,web 进程退出前 Close 一次(flush 残留)。未初始化时
|
||||
// 包级变量为 nil,调用方用 RecordView 这个 nil 安全的包装函数。
|
||||
type Recorder struct {
|
||||
store db.BoxViewsStore
|
||||
secret []byte
|
||||
|
||||
ch chan viewEvent
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// 关闭后仍有 handler 在跑(优雅关闭期),此时不能再往已关闭的 channel 发送。
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
type viewEvent struct {
|
||||
userID uint
|
||||
at time.Time
|
||||
visitor string
|
||||
}
|
||||
|
||||
const (
|
||||
// 队列长度:突发流量下攒 512 条足够,超出说明数据库已经跟不上了,
|
||||
// 此时记录本身也不是最重要的东西。
|
||||
recorderQueueSize = 512
|
||||
// flush 间隔:2 秒足够让「刷新页面就能看到数字」的体验成立,
|
||||
// 又能把同一秒内的几十次访问合并成一条 SQL。
|
||||
recorderFlushInterval = 2 * time.Second
|
||||
// 单批上限:一条 INSERT 里塞几百行会拉长事务,分批更稳。
|
||||
recorderBatchSize = 200
|
||||
)
|
||||
|
||||
// NewRecorder 创建记录器并立刻拉起后台 flush 循环。secret 用于给访客 IP 加盐哈希,
|
||||
// 通常传 server.xsrf_key。
|
||||
//
|
||||
// 为什么在构造函数里就启动 goroutine,而不是让调用方另起一个 Run:
|
||||
// 否则 Close 与 Run 之间存在竞态 —— 若进程在 Run 还没被调度时就退出,
|
||||
// wg.Wait 会立刻返回,队列里最后几秒的访问就丢了。这里把启动收进来,
|
||||
// wg 计数在构造时就加好,时序是确定的。
|
||||
func NewRecorder(store db.BoxViewsStore, secret string) *Recorder {
|
||||
r := &Recorder{
|
||||
store: store,
|
||||
secret: []byte("boxview:" + secret),
|
||||
ch: make(chan viewEvent, recorderQueueSize),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.loop()
|
||||
return r
|
||||
}
|
||||
|
||||
// loop 后台批量写入,直到 Close。
|
||||
func (r *Recorder) loop() {
|
||||
defer r.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(recorderFlushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
batch := make([]viewEvent, 0, recorderBatchSize)
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
r.write(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev := <-r.ch:
|
||||
batch = append(batch, ev)
|
||||
if len(batch) >= recorderBatchSize {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
case <-r.done:
|
||||
// 退出前把 channel 里剩下的取干净,否则优雅关闭期间的访问全丢。
|
||||
for {
|
||||
select {
|
||||
case ev := <-r.ch:
|
||||
batch = append(batch, ev)
|
||||
if len(batch) >= recorderBatchSize {
|
||||
flush()
|
||||
}
|
||||
default:
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close 停止接收新事件,flush 残留后返回。幂等,可安全 defer。
|
||||
func (r *Recorder) Close() {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.closed = true
|
||||
close(r.done)
|
||||
r.mu.Unlock()
|
||||
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
func (r *Recorder) write(batch []viewEvent) {
|
||||
if r.store == nil {
|
||||
return
|
||||
}
|
||||
opts := make([]db.RecordBoxViewOptions, 0, len(batch))
|
||||
for _, ev := range batch {
|
||||
opts = append(opts, db.RecordBoxViewOptions{
|
||||
UserID: ev.userID,
|
||||
ViewedOn: ev.at,
|
||||
Visitor: ev.visitor,
|
||||
})
|
||||
}
|
||||
|
||||
// 用独立 context:请求的 context 在响应写完后就被取消了,
|
||||
// 拿它写库会得到 "context canceled",统计静默丢失。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := r.store.RecordBatch(ctx, opts); err != nil {
|
||||
// 统计写失败不影响任何用户可见行为,只留日志。
|
||||
logrus.WithError(err).WithField("count", len(opts)).Warn("写入访问统计失败")
|
||||
}
|
||||
}
|
||||
|
||||
// record 非阻塞投递。队列满时直接丢弃:宁可少记几次,也不能让访客的页面
|
||||
// 因为统计而变慢。
|
||||
func (r *Recorder) record(userID uint, at time.Time, visitor string) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case r.ch <- viewEvent{userID: userID, at: at, visitor: visitor}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// visitorID 计算「当天」的访客标识:HMAC(secret, 日期 || IP)。
|
||||
//
|
||||
// 为什么带日期:不加日期就能长期追踪同一个 IP 的来访轨迹,那已经是一份
|
||||
// 可关联的行为记录。加日期盐之后,同一天内的重复访问仍能去重(UV 准确),
|
||||
// 跨天则完全不可关联 —— 代价是「近 7 天 UV」是 7 个日 UV 之和。
|
||||
//
|
||||
// 取前 16 字节十六进制 = 32 字符,碰撞概率在个人站量级可以忽略。
|
||||
func (r *Recorder) visitorID(ip string, at time.Time) string {
|
||||
mac := hmac.New(sha256.New, r.secret)
|
||||
mac.Write([]byte(at.Format("2006-01-02")))
|
||||
mac.Write([]byte{0})
|
||||
mac.Write([]byte(ip))
|
||||
return hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
// Views 全局记录器。未初始化(如单测、CLI)时为 nil。
|
||||
var Views *Recorder
|
||||
|
||||
// RecordView 记录一次箱主页访问。参数分别是箱主 ID、客户端 IP、请求。
|
||||
//
|
||||
// 会自行过滤掉:箱主本人、明显的爬虫、预取/预渲染请求。这些都不该算进
|
||||
// 「有多少人来看过我的箱」,否则箱主刷自己的页面就能看到漂亮的数字。
|
||||
func RecordView(userID uint, ip string, r *http.Request) {
|
||||
if Views == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
if isPrefetch(r) || isCrawler(r.UserAgent()) {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
Views.record(userID, now, Views.visitorID(ip, now))
|
||||
}
|
||||
|
||||
// isPrefetch 判断这是不是浏览器/链接预览的预取请求。
|
||||
// 预取会在用户根本没打开页面的情况下命中,算进 UV 会明显虚高。
|
||||
func isPrefetch(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
for _, h := range []string{"Sec-Purpose", "X-Purpose", "X-Moz"} {
|
||||
v := strings.ToLower(r.Header.Get(h))
|
||||
if strings.Contains(v, "prefetch") || strings.Contains(v, "prerender") || strings.Contains(v, "preview") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// crawlerHints 常见爬虫 UA 片段。
|
||||
//
|
||||
// 只做粗筛,不追求完整:目标是不让搜索引擎与监控探针把 UV 抬高。
|
||||
// 误判成本很低(少记一次),漏判成本也低(多记一次),所以用最简单的子串匹配。
|
||||
var crawlerHints = []string{
|
||||
"bot", "spider", "crawler", "crawl", "slurp", "scrapy",
|
||||
"curl/", "wget/", "python-requests", "python-urllib", "go-http-client",
|
||||
"httpclient", "okhttp", "axios", "headlesschrome", "phantomjs",
|
||||
"facebookexternalhit", "telegrambot", "whatsapp", "discordbot",
|
||||
"monitor", "uptime", "pingdom", "statuscake",
|
||||
}
|
||||
|
||||
func isCrawler(ua string) bool {
|
||||
if ua == "" {
|
||||
// 空 UA:绝大多数是脚本。真·浏览器一定带 UA。
|
||||
return true
|
||||
}
|
||||
ua = strings.ToLower(ua)
|
||||
for _, hint := range crawlerHints {
|
||||
if strings.Contains(ua, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RetentionDays 访问明细的保留天数。
|
||||
//
|
||||
// 明细表一行代表「某访客某天来过某箱」,体积随「日 UV × 箱数」增长。
|
||||
// 面板最长只看 30 天,但「历史最好的一天」要看全期,所以留一年出头:
|
||||
// 既能让箱主看到长期趋势,又不至于让表无限膨胀。
|
||||
const RetentionDays = 400
|
||||
|
||||
// StartMaintenance 启动每日清理协程。ctx 取消时退出。
|
||||
//
|
||||
// 启动时先跑一次:进程停机期间错过的清理在这里补上,不必等满 24 小时。
|
||||
func StartMaintenance(ctx context.Context, store db.BoxViewsStore) {
|
||||
go func() {
|
||||
run := func() {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
c, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
n, err := store.Purge(c, time.Now().AddDate(0, 0, -RetentionDays))
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("清理过期访问统计失败")
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
logrus.WithField("rows", n).Info("已清理过期的访问统计")
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user