- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
106 lines
2.8 KiB
Go
106 lines
2.8 KiB
Go
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)
|
||
}
|