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

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