feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
"strings"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
@@ -12,8 +13,6 @@ import (
"github.com/gin-gonic/gin"
)
const visitorCookie = "j13_vid"
func newVisitorID() string {
var b [16]byte
_, _ = rand.Read(b[:])
@@ -27,21 +26,64 @@ func newVisitorID() string {
)
}
// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF)
// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF + 限流)。
// 只组装事件并入队即返回,落库由后台协程攒批完成。
func (h *Handlers) TelemetryPageView(c *gin.Context) {
vid, _ := c.Cookie(visitorCookie)
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(visitorCookie, vid, 365*24*3600, "/", "", secure, true)
c.SetCookie(service.VisitorCookieName, vid, 365*24*3600, "/", "", secure, true)
}
loggedIn := middleware.CurrentUser(c) != nil
if err := h.Telemetry.RecordPageView(vid, loggedIn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
return
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})
}