// 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() } } }() }