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