Files
jiang13-bbs/backend/handler/visitstats.go
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

167 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}