新增 /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/爬虫过滤。
291 lines
8.5 KiB
Go
291 lines
8.5 KiB
Go
// 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()
|
|
}
|
|
}
|
|
}()
|
|
}
|