Files
jiang13-bbs/backend/handler/telemetry.go
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

106 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"strings"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
func newVisitorID() string {
var b [16]byte
_, _ = rand.Read(b[:])
// 伪 UUID 形态,满足 ValidVisitorID
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF + 限流)。
// 只组装事件并入队即返回,落库由后台协程攒批完成。
func (h *Handlers) TelemetryPageView(c *gin.Context) {
if !h.Visit.Enabled() {
c.Status(http.StatusNoContent)
return
}
var body struct {
Path string `json:"path"`
Ref string `json:"ref"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
path := strings.TrimSpace(body.Path)
if !strings.HasPrefix(path, "/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
if len(path) > 512 {
path = path[:512]
}
vid, _ := c.Cookie(service.VisitorCookieName)
if !service.ValidVisitorID(vid) {
vid = newVisitorID()
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
// HttpOnly:仅服务端读 UV,前端埋点不依赖可读 cookie
c.SetCookie(service.VisitorCookieName, vid, 365*24*3600, "/", "", secure, true)
}
ua := c.Request.UserAgent()
ev := model.VisitEvent{Path: path}
if bot := service.ClassifyBot(ua); bot != "" {
// UA 命中爬虫:只进明细,不计 PV/UV
ev.Kind = model.VisitKindBot
ev.Bot = bot
} else {
ev.Kind = model.VisitKindPageview
}
host, kind := service.ClassifyReferrer(body.Ref, c.Request.Host)
ev.RefHost = host
ev.RefKind = kind
if service.ValidVisitorID(vid) {
ev.VidHash = service.HashVisitorID(vid)
}
if u := middleware.CurrentUser(c); u != nil {
ev.UserID = u.ID
}
ev.IP = service.CanonicalIP(c.ClientIP())
p := service.ParseUserAgent(ua)
ev.Device = p.Kind
ev.OS = p.OS
ev.Browser = p.Browser
h.Visit.Enqueue(ev)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/admin/analytics/overview?range=7d|30d
func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) {
rangeQ := c.DefaultQuery("range", "7d")
actor := middleware.CurrentActor(c)
scoped := actor != nil && actor.Role == model.RoleBoardAdmin
var boardIDs []uint
if scoped {
boardIDs = actor.BoardIDs
}
data, err := h.Analytics.Overview(rangeQ, boardIDs, scoped)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"})
return
}
c.JSON(http.StatusOK, data)
}