feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
@@ -31,10 +31,11 @@ type Handlers struct {
|
||||
LegacyImport *service.LegacyImportService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Telemetry *service.TelemetryService
|
||||
Visit *service.VisitStatsService
|
||||
Analytics *service.AnalyticsService
|
||||
HidePwd *service.HidePasswordCookie
|
||||
Ads *service.AdService
|
||||
Sidebar *service.SidebarService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
123
backend/handler/sidebar.go
Normal file
123
backend/handler/sidebar.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/sidebar 公开侧栏载荷(布局 + 启用中的自定义工具)
|
||||
func (h *Handlers) GetSidebar(c *gin.Context) {
|
||||
payload, err := h.Sidebar.PublicPayload()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取侧栏配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// GET /api/admin/sidebar 管理端全量(生效配置 + 全部工具)
|
||||
func (h *Handlers) AdminGetSidebar(c *gin.Context) {
|
||||
cfg, err := h.Sidebar.GetConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取侧栏配置失败"})
|
||||
return
|
||||
}
|
||||
widgets, err := h.Sidebar.ListWidgets()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取侧栏工具失败"})
|
||||
return
|
||||
}
|
||||
if widgets == nil {
|
||||
widgets = []model.SidebarWidget{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"config": cfg, "widgets": widgets})
|
||||
}
|
||||
|
||||
// PUT /api/admin/sidebar/config 保存布局配置
|
||||
func (h *Handlers) AdminSaveSidebarConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
Config *service.SidebarConfig `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Config == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
saved, err := h.Sidebar.SaveConfig(*req.Config)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrSidebarInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "侧栏布局配置无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存侧栏配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"config": saved})
|
||||
}
|
||||
|
||||
// POST /api/admin/sidebar/widgets 新建自定义工具
|
||||
func (h *Handlers) AdminCreateSidebarWidget(c *gin.Context) {
|
||||
var w model.SidebarWidget
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Sidebar.CreateWidget(&w); err != nil {
|
||||
if errors.Is(err, service.ErrSidebarInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称或代码长度无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建侧栏工具失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"widget": w})
|
||||
}
|
||||
|
||||
// PUT /api/admin/sidebar/widgets/:id 更新工具(内容 + 启用态)
|
||||
func (h *Handlers) AdminUpdateSidebarWidget(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var body model.SidebarWidget
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Sidebar.UpdateWidget(uint(id), &body); err != nil {
|
||||
if errors.Is(err, service.ErrSidebarWidgetNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "侧栏工具不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrSidebarInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称或代码长度无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新侧栏工具失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DELETE /api/admin/sidebar/widgets/:id 删除工具并清理布局引用
|
||||
func (h *Handlers) AdminDeleteSidebarWidget(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Sidebar.DeleteWidget(uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrSidebarWidgetNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "侧栏工具不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除侧栏工具失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
166
backend/handler/visitstats.go
Normal file
166
backend/handler/visitstats.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============ 访问统计管理端 API(全部 RequirePerm(PermSettings)) ============
|
||||
|
||||
func statsRange(c *gin.Context) string { return c.DefaultQuery("range", "7d") }
|
||||
|
||||
// GET /api/admin/stats/overview
|
||||
func (h *Handlers) StatsOverview(c *gin.Context) {
|
||||
data, err := h.Visit.Overview(statsRange(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取概览失败", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/realtime
|
||||
func (h *Handlers) StatsRealtime(c *gin.Context) {
|
||||
data, err := h.Visit.Realtime()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取实时数据失败", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/sources
|
||||
func (h *Handlers) StatsSources(c *gin.Context) {
|
||||
data, err := h.Visit.Sources(statsRange(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取来源分析失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/pages?q=&page=&size=
|
||||
func (h *Handlers) StatsPages(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
data, err := h.Visit.Pages(statsRange(c), strings.TrimSpace(c.Query("q")), page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取受访页面失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/visitors
|
||||
func (h *Handlers) StatsVisitors(c *gin.Context) {
|
||||
data, err := h.Visit.Visitors(statsRange(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取访客画像失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/security
|
||||
func (h *Handlers) StatsSecurity(c *gin.Context) {
|
||||
data, err := h.Visit.Security(statsRange(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取安全数据失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/events?kind=&bot=&path=&ip=&start=&end=&page=&size=
|
||||
func (h *Handlers) StatsEvents(c *gin.Context) {
|
||||
f := service.VisitEventFilter{
|
||||
Kind: strings.TrimSpace(c.Query("kind")),
|
||||
Bot: strings.TrimSpace(c.Query("bot")),
|
||||
Path: strings.TrimSpace(c.Query("path")),
|
||||
IP: strings.TrimSpace(c.Query("ip")),
|
||||
}
|
||||
if v := c.Query("start"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Start = t
|
||||
}
|
||||
}
|
||||
if v := c.Query("end"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.End = t
|
||||
}
|
||||
}
|
||||
f.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
f.Size, _ = strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
data, err := h.Visit.Events(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取访问明细失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /api/admin/stats/settings — 开关/保留天数 + 容量信息
|
||||
func (h *Handlers) StatsGetSettings(c *gin.Context) {
|
||||
data, err := h.Visit.SettingsPayload()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取设置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// PUT /api/admin/stats/settings
|
||||
func (h *Handlers) StatsSaveSettings(c *gin.Context) {
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
RetentionDays *int `json:"retention_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||
return
|
||||
}
|
||||
cur, err := h.Visit.SettingsPayload()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取当前设置失败"})
|
||||
return
|
||||
}
|
||||
enabled := cur.Enabled
|
||||
if body.Enabled != nil {
|
||||
enabled = *body.Enabled
|
||||
}
|
||||
days := cur.RetentionDays
|
||||
if body.RetentionDays != nil {
|
||||
days = *body.RetentionDays
|
||||
}
|
||||
if err := h.Visit.SaveSettings(enabled, days); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "保存失败,保留天数需在 7–365 之间"})
|
||||
return
|
||||
}
|
||||
data, err := h.Visit.SettingsPayload()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取设置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// POST /api/admin/stats/purge — 立即清理 N 天前明细(异步分批执行,受理即返回)
|
||||
func (h *Handlers) StatsPurge(c *gin.Context) {
|
||||
var body struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if !h.Visit.PurgeBefore(body.Days) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "天数需在 7–365 之间"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
Reference in New Issue
Block a user