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