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})
|
||||
}
|
||||
62
backend/middleware/visit_capture.go
Normal file
62
backend/middleware/visit_capture.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VisitCapture 服务端访问捕获:只记「UA 命中爬虫库」与「响应状态 ≥ 400」两类请求,
|
||||
// 普通人类浏览由客户端埋点上报(互补、不重复)。事件非阻塞入队,不影响响应。
|
||||
func VisitCapture(svc *service.VisitStatsService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
if svc == nil || !svc.Enabled() {
|
||||
return
|
||||
}
|
||||
// 静态资源与健康检查/WS 无统计意义
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/uploads") || path == "/health" || path == "/api/ws" {
|
||||
return
|
||||
}
|
||||
|
||||
ua := c.Request.UserAgent()
|
||||
bot := service.ClassifyBot(ua)
|
||||
status := c.Writer.Status()
|
||||
if bot == "" && status < 400 {
|
||||
return // 正常人类请求:不记
|
||||
}
|
||||
|
||||
ev := model.VisitEvent{}
|
||||
if bot != "" {
|
||||
ev.Kind = model.VisitKindBot
|
||||
ev.Bot = bot
|
||||
} else {
|
||||
ev.Kind = model.VisitKindProbe
|
||||
ev.Status = status
|
||||
}
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
ev.Path = path + "?" + q
|
||||
} else {
|
||||
ev.Path = path
|
||||
}
|
||||
if len(ev.Path) > 512 {
|
||||
ev.Path = ev.Path[:512]
|
||||
}
|
||||
ev.IP = service.CanonicalIP(c.ClientIP())
|
||||
if vid, err := c.Cookie(service.VisitorCookieName); err == nil && service.ValidVisitorID(vid) {
|
||||
ev.VidHash = service.HashVisitorID(vid)
|
||||
}
|
||||
if u := CurrentUser(c); u != nil {
|
||||
ev.UserID = u.ID
|
||||
}
|
||||
p := service.ParseUserAgent(ua)
|
||||
ev.Device = p.Kind
|
||||
ev.OS = p.OS
|
||||
ev.Browser = p.Browser
|
||||
svc.Enqueue(ev)
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,10 @@ func InitDB(dsn string) error {
|
||||
if err := db.AutoMigrate(
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{},
|
||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
||||
&ImportRecord{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
|
||||
@@ -455,6 +455,19 @@ type SiteSetting struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SidebarWidget 自定义侧栏工具(站长 HTML+CSS+JS 代码块,前台原样渲染执行)
|
||||
// Enabled 不加 gorm default 标签:规避布尔零值省略陷阱,创建时由代码显式赋值
|
||||
type SidebarWidget struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Html string `gorm:"type:text" json:"html"`
|
||||
Css string `gorm:"type:text" json:"css"`
|
||||
Js string `gorm:"type:text" json:"js"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
||||
type SiteDailyStats struct {
|
||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||
@@ -470,6 +483,31 @@ type SiteDailyVisitor struct {
|
||||
VidHash string `gorm:"size:64;primaryKey" json:"-"`
|
||||
}
|
||||
|
||||
// 访问事件类型:pageview 人类浏览(计 PV/UV);bot 爬虫;probe 异常/错误响应
|
||||
const (
|
||||
VisitKindPageview = "pageview"
|
||||
VisitKindBot = "bot"
|
||||
VisitKindProbe = "probe"
|
||||
)
|
||||
|
||||
// VisitEvent 单次访问事件(内存攒批落库,保留期滚动清理)
|
||||
type VisitEvent struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `gorm:"index:idx_visit_created" json:"created_at"`
|
||||
Kind string `gorm:"size:16;index:idx_visit_kind_time,priority:1;not null;default:'pageview'" json:"kind"`
|
||||
Path string `gorm:"size:512;not null;default:''" json:"path"`
|
||||
RefHost string `gorm:"size:128;not null;default:''" json:"ref_host"`
|
||||
RefKind string `gorm:"size:16;not null;default:'direct'" json:"ref_kind"` // direct|internal|search|social|external
|
||||
VidHash string `gorm:"size:64;not null;default:''" json:"-"`
|
||||
UserID uint `gorm:"not null;default:0" json:"user_id"`
|
||||
IP string `gorm:"size:45;not null;default:''" json:"ip"`
|
||||
Device string `gorm:"size:16;not null;default:''" json:"device"` // computer|mobile|tablet
|
||||
OS string `gorm:"size:32;not null;default:''" json:"os"`
|
||||
Browser string `gorm:"size:32;not null;default:''" json:"browser"`
|
||||
Bot string `gorm:"size:64;not null;default:''" json:"bot"` // 非空 = 爬虫名
|
||||
Status int `gorm:"not null;default:0" json:"status"` // probe 的响应状态码
|
||||
}
|
||||
|
||||
// 附件类型
|
||||
const (
|
||||
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -29,6 +30,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
var proxies []string
|
||||
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||
proxies = strings.Split(v, ",")
|
||||
} else {
|
||||
// 未配置信任代理时 ClientIP 退化为 TCP 直连对端:反代/容器部署会记录内网地址
|
||||
log.Println("警告: TRUSTED_PROXIES 未配置,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
|
||||
}
|
||||
if err := r.SetTrustedProxies(proxies); err != nil {
|
||||
return nil, err
|
||||
@@ -73,7 +77,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||
telemetrySvc := service.NewTelemetryService(model.DB)
|
||||
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
|
||||
analyticsSvc := service.NewAnalyticsService(model.DB)
|
||||
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
|
||||
overviewSvc.WithAds(adSvc)
|
||||
@@ -85,6 +89,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}
|
||||
limiter := service.DefaultRateLimiter()
|
||||
|
||||
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
|
||||
pipeCtx, pipeCancel := context.WithCancel(context.Background())
|
||||
defer pipeCancel()
|
||||
go visitSvc.StartFlusher(pipeCtx)
|
||||
go visitSvc.RunRetention(pipeCtx)
|
||||
|
||||
h := &handler.Handlers{
|
||||
Ops: ops,
|
||||
Cfg: cfg,
|
||||
@@ -107,10 +117,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
LegacyImport: legacyImportSvc,
|
||||
Moderation: moderationSvc,
|
||||
Chat: chatSvc,
|
||||
Telemetry: telemetrySvc,
|
||||
Visit: visitSvc,
|
||||
Analytics: analyticsSvc,
|
||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||
Ads: adSvc,
|
||||
Sidebar: service.NewSidebarService(model.DB),
|
||||
}
|
||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||
notifSvc.OnNotifyNew = func(userID uint) {
|
||||
@@ -127,6 +138,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||
r.Use(authMW.OptionalAuth(), h.RuntimeGuard, h.BusinessQuota)
|
||||
|
||||
// 访问捕获:仅记爬虫 UA 与 ≥400 响应(人类浏览走客户端埋点);
|
||||
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
||||
r.Use(middleware.VisitCapture(visitSvc))
|
||||
|
||||
// 上传文件静态服务(data/uploads → /uploads)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
|
||||
@@ -143,6 +158,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/boards", h.Boards)
|
||||
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||
pubAPI.GET("/overview", h.Overview)
|
||||
pubAPI.GET("/sidebar", h.GetSidebar)
|
||||
pubAPI.GET("/posts", h.Posts)
|
||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
||||
@@ -159,7 +175,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/media/:object", h.PublicObject)
|
||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
|
||||
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateTelemetry), h.TelemetryPageView)
|
||||
pubAPI.POST("/register", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
||||
@@ -277,6 +293,21 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
// 站点分析概览(任意 staff;板管按授权板过滤)
|
||||
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
|
||||
|
||||
// 访问统计(仅超管/站长):8 个 Tab 的数据源
|
||||
statsAPI := staffAPI.Group("/stats", authMW.RequirePerm(service.PermSettings))
|
||||
{
|
||||
statsAPI.GET("/overview", h.StatsOverview)
|
||||
statsAPI.GET("/realtime", h.StatsRealtime)
|
||||
statsAPI.GET("/sources", h.StatsSources)
|
||||
statsAPI.GET("/pages", h.StatsPages)
|
||||
statsAPI.GET("/visitors", h.StatsVisitors)
|
||||
statsAPI.GET("/security", h.StatsSecurity)
|
||||
statsAPI.GET("/events", h.StatsEvents)
|
||||
statsAPI.GET("/settings", h.StatsGetSettings)
|
||||
statsAPI.PUT("/settings", h.StatsSaveSettings)
|
||||
statsAPI.POST("/purge", h.StatsPurge)
|
||||
}
|
||||
|
||||
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||
// (见下方 msgAPI)
|
||||
|
||||
@@ -305,6 +336,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
adsAPI.GET("/sponsors/config", h.AdminGetSponsorsConfig)
|
||||
adsAPI.PUT("/sponsors/config", h.AdminSaveSponsorsConfig)
|
||||
|
||||
// 侧栏管理(超级管理员/站长):布局配置 + 自定义 HTML 工具
|
||||
staffAPI.GET("/sidebar", authMW.RequirePerm(service.PermSettings), h.AdminGetSidebar)
|
||||
staffAPI.PUT("/sidebar/config", authMW.RequirePerm(service.PermSettings), h.AdminSaveSidebarConfig)
|
||||
staffAPI.POST("/sidebar/widgets", authMW.RequirePerm(service.PermSettings), h.AdminCreateSidebarWidget)
|
||||
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
||||
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
||||
|
||||
// 站点设置(超级管理员/站长)
|
||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||
|
||||
@@ -84,6 +84,7 @@ const (
|
||||
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
|
||||
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
|
||||
RateCaptcha = "captcha" // 验证码刷新 30/分钟
|
||||
RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
@@ -100,5 +101,6 @@ func DefaultRateLimiter() *RateLimiter {
|
||||
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
||||
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
|
||||
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
|
||||
rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
|
||||
return rl
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ const (
|
||||
SettingKeyAttachmentMaxCount = "attachment_max_count"
|
||||
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
|
||||
SettingKeyImageMaxMB = "image_max_mb"
|
||||
// SettingKeyAnalyticsEnabled 访问统计采集开关;缺行视为开启(默认开)
|
||||
SettingKeyAnalyticsEnabled = "analytics_enabled"
|
||||
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
|
||||
SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -74,6 +78,10 @@ const (
|
||||
MinAttachmentMaxCount = 1
|
||||
MaxAttachmentMaxCount = 20
|
||||
MaxAttachmentExtCount = 80
|
||||
|
||||
DefaultAnalyticsRetentionDays = 90
|
||||
MinAnalyticsRetentionDays = 7
|
||||
MaxAnalyticsRetentionDays = 365
|
||||
)
|
||||
|
||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||
@@ -880,3 +888,47 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
|
||||
}
|
||||
return int64(mb) << 20, nil
|
||||
}
|
||||
|
||||
// AnalyticsEnabled 访问统计采集开关。缺行视为开启。
|
||||
func (s *SettingService) AnalyticsEnabled() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyAnalyticsEnabled)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
return parseBoolDefaultTrue(v, found), nil
|
||||
}
|
||||
|
||||
// SetAnalyticsEnabled 持久化采集开关;开启时删键保持「缺行=默认开」。
|
||||
func (s *SettingService) SetAnalyticsEnabled(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyAnalyticsEnabled, on)
|
||||
}
|
||||
|
||||
// AnalyticsRetentionDays 访问明细保留天数。缺行=90;钳制 7–365。
|
||||
func (s *SettingService) AnalyticsRetentionDays() (int, error) {
|
||||
v, found, err := s.getValue(SettingKeyAnalyticsRetentionDays)
|
||||
if err != nil {
|
||||
return DefaultAnalyticsRetentionDays, err
|
||||
}
|
||||
if !found {
|
||||
return DefaultAnalyticsRetentionDays, nil
|
||||
}
|
||||
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||
if convErr != nil || n < MinAnalyticsRetentionDays {
|
||||
return DefaultAnalyticsRetentionDays, nil
|
||||
}
|
||||
if n > MaxAnalyticsRetentionDays {
|
||||
return MaxAnalyticsRetentionDays, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetAnalyticsRetentionDays 持久化保留天数;等于默认值时删键。
|
||||
func (s *SettingService) SetAnalyticsRetentionDays(days int) error {
|
||||
if days < MinAnalyticsRetentionDays || days > MaxAnalyticsRetentionDays {
|
||||
return ErrInvalidSiteSetting
|
||||
}
|
||||
if days == DefaultAnalyticsRetentionDays {
|
||||
return s.deleteKey(SettingKeyAnalyticsRetentionDays)
|
||||
}
|
||||
return s.putValue(SettingKeyAnalyticsRetentionDays, strconv.Itoa(days))
|
||||
}
|
||||
|
||||
453
backend/service/sidebar.go
Normal file
453
backend/service/sidebar.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SettingKeySidebarConfig 侧栏布局配置在 site_settings 中的键(沿 ads_config JSON 先例)
|
||||
const SettingKeySidebarConfig = "sidebar_config"
|
||||
|
||||
const (
|
||||
// SidebarConfigVersion 配置结构版本;升级结构时递增并做迁移
|
||||
SidebarConfigVersion = 1
|
||||
// SidebarMaxBlocksPerSide 每侧最多放置的块数
|
||||
SidebarMaxBlocksPerSide = 24
|
||||
// SidebarWidgetMaxNameRunes 自定义工具备注名上限(字符数)
|
||||
SidebarWidgetMaxNameRunes = 40
|
||||
// SidebarWidgetMaxCodeBytes 单段代码(HTML/CSS/JS)字节上限
|
||||
SidebarWidgetMaxCodeBytes = 64 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrSidebarInvalid 侧栏配置/工具参数校验失败
|
||||
ErrSidebarInvalid = errors.New("侧栏配置参数无效")
|
||||
// ErrSidebarWidgetNotFound 自定义工具不存在
|
||||
ErrSidebarWidgetNotFound = errors.New("侧栏工具不存在")
|
||||
)
|
||||
|
||||
// 内置块 key 白名单(与前端 lib/sidebarMeta.ts 保持一致)
|
||||
var sidebarBuiltinKeys = map[string]map[string]struct{}{
|
||||
"home": setOf("board_nav", "pulse", "announcements", "ads", "sponsors",
|
||||
"hot", "new_users", "active_users", "friend_links", "about_site"),
|
||||
// board scope 仅右栏可配;board_* 不允许放进 home/post
|
||||
"board": setOf("board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"),
|
||||
"post": setOf("post_board", "post_author", "post_author_posts", "post_toc"),
|
||||
}
|
||||
|
||||
// sidebarScopes 合法页面范围(board 布局只存 right;board_* 块仅限 board scope)
|
||||
var sidebarScopes = setOf("home", "board", "post")
|
||||
|
||||
func setOf(keys ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
m[k] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SidebarBlockRef 侧栏块引用:内置块用 Key,自定义工具用 WidgetID(二选一)
|
||||
type SidebarBlockRef struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
WidgetID uint `json:"widget,omitempty"`
|
||||
}
|
||||
|
||||
// SidebarScopeConfig 单个页面范围的侧栏布局;指针切片区分「未配置(回落默认)」与「显式清空」
|
||||
type SidebarScopeConfig struct {
|
||||
Left *[]SidebarBlockRef `json:"left,omitempty"`
|
||||
Right *[]SidebarBlockRef `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// SidebarConfig 全站侧栏布局配置
|
||||
type SidebarConfig struct {
|
||||
Version int `json:"version"`
|
||||
Scopes map[string]SidebarScopeConfig `json:"scopes"`
|
||||
}
|
||||
|
||||
// SidebarPublicWidget 前台可见的自定义工具内容(仅启用项)
|
||||
type SidebarPublicWidget struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Html string `json:"html"`
|
||||
Css string `json:"css"`
|
||||
Js string `json:"js"`
|
||||
}
|
||||
|
||||
// SidebarPublicPayload 公开侧栏载荷:生效布局 + 启用中的自定义工具
|
||||
type SidebarPublicPayload struct {
|
||||
Config SidebarConfig `json:"config"`
|
||||
Widgets []SidebarPublicWidget `json:"widgets"`
|
||||
}
|
||||
|
||||
func refKey(r SidebarBlockRef) string {
|
||||
if r.WidgetID > 0 {
|
||||
return "widget:" + itoa(int(r.WidgetID))
|
||||
}
|
||||
return "key:" + r.Key
|
||||
}
|
||||
|
||||
func refsEqualRefs(a, b []SidebarBlockRef) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func defaultScopeLeft(scope string) *[]SidebarBlockRef {
|
||||
var refs []SidebarBlockRef
|
||||
switch scope {
|
||||
case "home":
|
||||
refs = []SidebarBlockRef{{Key: "board_nav"}}
|
||||
case "post":
|
||||
refs = []SidebarBlockRef{{Key: "post_board"}, {Key: "post_author"}, {Key: "post_author_posts"}}
|
||||
default: // board:左栏不可配,恒为空
|
||||
refs = []SidebarBlockRef{}
|
||||
}
|
||||
return &refs
|
||||
}
|
||||
|
||||
func defaultScopeRight(scope string) *[]SidebarBlockRef {
|
||||
var refs []SidebarBlockRef
|
||||
switch scope {
|
||||
case "home":
|
||||
refs = []SidebarBlockRef{
|
||||
{Key: "pulse"}, {Key: "announcements"}, {Key: "ads"}, {Key: "sponsors"},
|
||||
{Key: "hot"}, {Key: "new_users"}, {Key: "active_users"},
|
||||
{Key: "friend_links"}, {Key: "about_site"},
|
||||
}
|
||||
case "board":
|
||||
refs = []SidebarBlockRef{
|
||||
{Key: "board_moderators"}, {Key: "board_intro"}, {Key: "board_pulse"},
|
||||
{Key: "board_hot"}, {Key: "board_active"},
|
||||
}
|
||||
default: // post
|
||||
refs = []SidebarBlockRef{{Key: "post_toc"}}
|
||||
}
|
||||
return &refs
|
||||
}
|
||||
|
||||
// DefaultSidebarConfig 当前站点的默认侧栏布局(与历史硬编码渲染一致,零迁移)
|
||||
func DefaultSidebarConfig() SidebarConfig {
|
||||
return SidebarConfig{
|
||||
Version: SidebarConfigVersion,
|
||||
Scopes: map[string]SidebarScopeConfig{
|
||||
"home": {Left: defaultScopeLeft("home"), Right: defaultScopeRight("home")},
|
||||
"board": {Left: defaultScopeLeft("board"), Right: defaultScopeRight("board")},
|
||||
"post": {Left: defaultScopeLeft("post"), Right: defaultScopeRight("post")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SidebarService 侧栏布局配置 + 自定义工具
|
||||
type SidebarService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSidebarService(db *gorm.DB) *SidebarService {
|
||||
return &SidebarService{db: db}
|
||||
}
|
||||
|
||||
// fillScopeDefaults 补齐缺失的 scope 与侧栏(nil 指针 = 未配置 → 默认布局)
|
||||
func fillScopeDefaults(cfg SidebarConfig) SidebarConfig {
|
||||
out := DefaultSidebarConfig()
|
||||
for scope, sc := range cfg.Scopes {
|
||||
dst, ok := out.Scopes[scope]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sc.Left != nil {
|
||||
dst.Left = sc.Left
|
||||
}
|
||||
if sc.Right != nil {
|
||||
dst.Right = sc.Right
|
||||
}
|
||||
out.Scopes[scope] = dst
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetConfig 读取生效配置(缺键/坏 JSON 回落默认布局)
|
||||
func (s *SidebarService) GetConfig() (SidebarConfig, error) {
|
||||
var row model.SiteSetting
|
||||
err := s.db.Where("key = ?", SettingKeySidebarConfig).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return DefaultSidebarConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultSidebarConfig(), err
|
||||
}
|
||||
if strings.TrimSpace(row.Value) == "" {
|
||||
return DefaultSidebarConfig(), nil
|
||||
}
|
||||
var cfg SidebarConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
||||
return DefaultSidebarConfig(), nil
|
||||
}
|
||||
return fillScopeDefaults(cfg), nil
|
||||
}
|
||||
|
||||
// validateConfig 校验并归一化;widgetExists 用于校验工具引用
|
||||
func (s *SidebarService) validateConfig(in SidebarConfig, widgetExists func(uint) (bool, error)) (SidebarConfig, error) {
|
||||
if len(in.Scopes) == 0 {
|
||||
return SidebarConfig{}, ErrSidebarInvalid
|
||||
}
|
||||
out := SidebarConfig{Version: SidebarConfigVersion, Scopes: map[string]SidebarScopeConfig{}}
|
||||
for scope, sc := range in.Scopes {
|
||||
if _, ok := sidebarScopes[scope]; !ok {
|
||||
return SidebarConfig{}, ErrSidebarInvalid
|
||||
}
|
||||
keys := sidebarBuiltinKeys[scope]
|
||||
seen := map[string]struct{}{}
|
||||
left, err := s.validateSide(scope, "left", sc.Left, keys, seen, widgetExists)
|
||||
if err != nil {
|
||||
return SidebarConfig{}, err
|
||||
}
|
||||
right, err := s.validateSide(scope, "right", sc.Right, keys, seen, widgetExists)
|
||||
if err != nil {
|
||||
return SidebarConfig{}, err
|
||||
}
|
||||
// board 布局只存 right(左栏沿用 home 配置)
|
||||
if scope == "board" {
|
||||
left = &[]SidebarBlockRef{}
|
||||
}
|
||||
out.Scopes[scope] = SidebarScopeConfig{Left: left, Right: right}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SidebarService) validateSide(scope, side string, list *[]SidebarBlockRef, builtinKeys map[string]struct{}, seen map[string]struct{}, widgetExists func(uint) (bool, error)) (*[]SidebarBlockRef, error) {
|
||||
if list == nil {
|
||||
return defaultScopeSide(scope, side), nil
|
||||
}
|
||||
if len(*list) > SidebarMaxBlocksPerSide {
|
||||
return nil, ErrSidebarInvalid
|
||||
}
|
||||
out := make([]SidebarBlockRef, 0, len(*list))
|
||||
for _, r := range *list {
|
||||
hasKey := r.Key != ""
|
||||
hasWidget := r.WidgetID > 0
|
||||
if hasKey == hasWidget { // 二选一,且必填其一
|
||||
return nil, ErrSidebarInvalid
|
||||
}
|
||||
if hasKey {
|
||||
if _, ok := builtinKeys[r.Key]; !ok {
|
||||
return nil, ErrSidebarInvalid
|
||||
}
|
||||
} else {
|
||||
ok, err := widgetExists(r.WidgetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrSidebarInvalid
|
||||
}
|
||||
}
|
||||
k := refKey(r)
|
||||
if _, dup := seen[k]; dup {
|
||||
return nil, ErrSidebarInvalid
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, r)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func defaultScopeSide(scope, side string) *[]SidebarBlockRef {
|
||||
if side == "left" {
|
||||
return defaultScopeLeft(scope)
|
||||
}
|
||||
return defaultScopeRight(scope)
|
||||
}
|
||||
|
||||
// SaveConfig 保存布局配置(board.left 丢弃)
|
||||
func (s *SidebarService) SaveConfig(in SidebarConfig) (SidebarConfig, error) {
|
||||
out, err := s.validateConfig(in, s.widgetExists)
|
||||
if err != nil {
|
||||
return SidebarConfig{}, err
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return SidebarConfig{}, err
|
||||
}
|
||||
row := model.SiteSetting{Key: SettingKeySidebarConfig, Value: string(b), UpdatedAt: time.Now()}
|
||||
if err := s.db.Save(&row).Error; err != nil {
|
||||
return SidebarConfig{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// widgetExists 批量存在性检查走缓存式的单查即可(引用量 ≤48)
|
||||
func (s *SidebarService) widgetExists(id uint) (bool, error) {
|
||||
var n int64
|
||||
if err := s.db.Model(&model.SidebarWidget{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ListWidgets 管理端工具全量列表
|
||||
func (s *SidebarService) ListWidgets() ([]model.SidebarWidget, error) {
|
||||
var list []model.SidebarWidget
|
||||
err := s.db.Order("id ASC").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func validateWidgetInput(name, html, css, js string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > SidebarWidgetMaxNameRunes {
|
||||
return "", ErrSidebarInvalid
|
||||
}
|
||||
for _, code := range []string{html, css, js} {
|
||||
if len(code) > SidebarWidgetMaxCodeBytes {
|
||||
return "", ErrSidebarInvalid
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// CreateWidget 新建自定义工具
|
||||
func (s *SidebarService) CreateWidget(w *model.SidebarWidget) error {
|
||||
name, err := validateWidgetInput(w.Name, w.Html, w.Css, w.Js)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Name = name
|
||||
w.Enabled = true
|
||||
now := time.Now()
|
||||
w.CreatedAt = now
|
||||
w.UpdatedAt = now
|
||||
return s.db.Create(w).Error
|
||||
}
|
||||
|
||||
// UpdateWidget 全量更新工具内容
|
||||
func (s *SidebarService) UpdateWidget(id uint, in *model.SidebarWidget) error {
|
||||
name, err := validateWidgetInput(in.Name, in.Html, in.Css, in.Js)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var w model.SidebarWidget
|
||||
if err := s.db.First(&w, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSidebarWidgetNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 用 Select 强制写全部列,规避布尔零值省略
|
||||
return s.db.Model(&w).Select("name", "html", "css", "js", "enabled", "updated_at").
|
||||
Updates(model.SidebarWidget{Name: name, Html: in.Html, Css: in.Css, Js: in.Js,
|
||||
Enabled: in.Enabled, UpdatedAt: time.Now()}).Error
|
||||
}
|
||||
|
||||
// SetWidgetEnabled 仅切换启用状态
|
||||
func (s *SidebarService) SetWidgetEnabled(id uint, enabled bool) error {
|
||||
var w model.SidebarWidget
|
||||
if err := s.db.First(&w, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSidebarWidgetNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.db.Model(&w).Select("enabled", "updated_at").
|
||||
Updates(model.SidebarWidget{Enabled: enabled, UpdatedAt: time.Now()}).Error
|
||||
}
|
||||
|
||||
// DeleteWidget 删除工具并同事务清理配置中的引用
|
||||
func (s *SidebarService) DeleteWidget(id uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Delete(&model.SidebarWidget{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrSidebarWidgetNotFound
|
||||
}
|
||||
cfg, err := loadConfigTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
for scope, sc := range cfg.Scopes {
|
||||
if sc.Left != nil {
|
||||
filtered := filterOutWidget(*sc.Left, id)
|
||||
if !refsEqualRefs(filtered, *sc.Left) {
|
||||
sc.Left = &filtered
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if sc.Right != nil {
|
||||
filtered := filterOutWidget(*sc.Right, id)
|
||||
if !refsEqualRefs(filtered, *sc.Right) {
|
||||
sc.Right = &filtered
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
cfg.Scopes[scope] = sc
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.SiteSetting{}).Where("key = ?", SettingKeySidebarConfig).
|
||||
Update("value", string(b)).Error
|
||||
})
|
||||
}
|
||||
|
||||
func filterOutWidget(refs []SidebarBlockRef, id uint) []SidebarBlockRef {
|
||||
out := make([]SidebarBlockRef, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
if r.WidgetID == id {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadConfigTx(tx *gorm.DB) (SidebarConfig, error) {
|
||||
var row model.SiteSetting
|
||||
err := tx.Where("key = ?", SettingKeySidebarConfig).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return DefaultSidebarConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return SidebarConfig{}, err
|
||||
}
|
||||
var cfg SidebarConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
||||
return DefaultSidebarConfig(), nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// PublicPayload 前台载荷:生效布局 + 启用中的工具(按 id 升序)
|
||||
func (s *SidebarService) PublicPayload() (*SidebarPublicPayload, error) {
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []model.SidebarWidget
|
||||
if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
widgets := make([]SidebarPublicWidget, 0, len(rows))
|
||||
for _, w := range rows {
|
||||
widgets = append(widgets, SidebarPublicWidget{
|
||||
ID: w.ID, Name: w.Name, Html: w.Html, Css: w.Css, Js: w.Js,
|
||||
})
|
||||
}
|
||||
return &SidebarPublicPayload{Config: cfg, Widgets: widgets}, nil
|
||||
}
|
||||
224
backend/service/sidebar_test.go
Normal file
224
backend/service/sidebar_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newSidebarTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SiteSetting{}, &model.SidebarWidget{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSidebarGetConfigDefaults(t *testing.T) {
|
||||
db := newSidebarTestDB(t)
|
||||
s := NewSidebarService(db)
|
||||
|
||||
// 无键 → 默认布局
|
||||
cfg, err := s.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig: %v", err)
|
||||
}
|
||||
def := DefaultSidebarConfig()
|
||||
home := cfg.Scopes["home"]
|
||||
if home.Left == nil || (*home.Left)[0].Key != "board_nav" {
|
||||
t.Fatal("home.left 默认应为 board_nav")
|
||||
}
|
||||
if len(*home.Right) != len(*def.Scopes["home"].Right) {
|
||||
t.Fatalf("home.right 默认长度不符: %d", len(*home.Right))
|
||||
}
|
||||
// board.left 恒为空
|
||||
if bl := cfg.Scopes["board"].Left; bl == nil || len(*bl) != 0 {
|
||||
t.Fatal("board.left 默认应为空")
|
||||
}
|
||||
|
||||
// 坏 JSON → 回落默认
|
||||
if err := db.Create(&model.SiteSetting{Key: SettingKeySidebarConfig, Value: "{oops"}).Error; err != nil {
|
||||
t.Fatalf("seed bad json: %v", err)
|
||||
}
|
||||
cfg2, err := s.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig bad json: %v", err)
|
||||
}
|
||||
if (*cfg2.Scopes["home"].Left)[0].Key != "board_nav" {
|
||||
t.Fatal("坏 JSON 应回落默认布局")
|
||||
}
|
||||
|
||||
// 显式清空的侧栏要保持为空(非默认回填)
|
||||
empty := []SidebarBlockRef{}
|
||||
saved, err := s.SaveConfig(SidebarConfig{
|
||||
Version: SidebarConfigVersion,
|
||||
Scopes: map[string]SidebarScopeConfig{
|
||||
"home": {Right: &empty},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
if saved.Scopes["home"].Right == nil || len(*saved.Scopes["home"].Right) != 0 {
|
||||
t.Fatal("显式清空的 home.right 应保持为空")
|
||||
}
|
||||
cfg3, _ := s.GetConfig()
|
||||
if len(*cfg3.Scopes["home"].Right) != 0 {
|
||||
t.Fatal("读取时显式清空不应被默认回填")
|
||||
}
|
||||
if len(*cfg3.Scopes["post"].Left) == 0 {
|
||||
t.Fatal("未配置的 post.left 应回落默认")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarSaveConfigValidation(t *testing.T) {
|
||||
db := newSidebarTestDB(t)
|
||||
s := NewSidebarService(db)
|
||||
|
||||
mk := func(scopes map[string]SidebarScopeConfig) SidebarConfig {
|
||||
return SidebarConfig{Version: SidebarConfigVersion, Scopes: scopes}
|
||||
}
|
||||
side := func(refs []SidebarBlockRef) *[]SidebarBlockRef { return &refs }
|
||||
|
||||
// 非法 scope
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"unknown": {Right: side([]SidebarBlockRef{{Key: "pulse"}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("非法 scope 应被拒绝")
|
||||
}
|
||||
// 非法 key(board_* 不允许放进 home)
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{Key: "board_hot"}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("跨 scope 的内置 key 应被拒绝")
|
||||
}
|
||||
// 引用不存在的自定义工具
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{WidgetID: 999}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("不存在的 widget 引用应被拒绝")
|
||||
}
|
||||
// 同 scope 重复引用
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{Key: "pulse"}, {Key: "pulse"}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("同 scope 重复引用应被拒绝")
|
||||
}
|
||||
// 超 24 块
|
||||
many := make([]SidebarBlockRef, SidebarMaxBlocksPerSide+1)
|
||||
for i := range many {
|
||||
many[i] = SidebarBlockRef{Key: "pulse"}
|
||||
}
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: &many},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("超上限应被拒绝")
|
||||
}
|
||||
// key 与 widget 同时出现
|
||||
if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"home": {Right: side([]SidebarBlockRef{{Key: "pulse", WidgetID: 1}})},
|
||||
})); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("key 与 widget 二选一应被强制")
|
||||
}
|
||||
// 空 scopes
|
||||
if _, err := s.SaveConfig(SidebarConfig{Version: 1}); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("空 scopes 应被拒绝")
|
||||
}
|
||||
// board.left 保存后应被丢弃(即使引用合法的 board 块)
|
||||
saved, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
|
||||
"board": {Left: side([]SidebarBlockRef{{Key: "board_intro"}}), Right: side([]SidebarBlockRef{{Key: "board_hot"}})},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("board 保存失败: %v", err)
|
||||
}
|
||||
if len(*saved.Scopes["board"].Left) != 0 {
|
||||
t.Fatal("board.left 应被丢弃为空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarWidgetCRUDAndRefCleanup(t *testing.T) {
|
||||
db := newSidebarTestDB(t)
|
||||
s := NewSidebarService(db)
|
||||
|
||||
// 名称校验
|
||||
w := &model.SidebarWidget{Name: " ", Html: "<b>x</b>"}
|
||||
if err := s.CreateWidget(w); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("空名称应被拒绝")
|
||||
}
|
||||
w = &model.SidebarWidget{Name: strings.Repeat("字", SidebarWidgetMaxNameRunes+1)}
|
||||
if err := s.CreateWidget(w); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("超长名称应被拒绝")
|
||||
}
|
||||
big := strings.Repeat("a", SidebarWidgetMaxCodeBytes+1)
|
||||
if err := s.CreateWidget(&model.SidebarWidget{Name: "t", Html: big}); !errors.Is(err, ErrSidebarInvalid) {
|
||||
t.Fatal("超 64KB 代码应被拒绝")
|
||||
}
|
||||
|
||||
// 正常创建 + 放入配置
|
||||
w = &model.SidebarWidget{Name: "跑马灯", Html: "<div>hi</div>", Enabled: false}
|
||||
if err := s.CreateWidget(w); err != nil {
|
||||
t.Fatalf("CreateWidget: %v", err)
|
||||
}
|
||||
if w.ID == 0 || !w.Enabled {
|
||||
t.Fatalf("创建后应启用且有 ID: %+v", w)
|
||||
}
|
||||
if _, err := s.SaveConfig(SidebarConfig{Version: SidebarConfigVersion, Scopes: map[string]SidebarScopeConfig{
|
||||
"home": {Right: func() *[]SidebarBlockRef {
|
||||
r := []SidebarBlockRef{{Key: "pulse"}, {WidgetID: w.ID}}
|
||||
return &r
|
||||
}()},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveConfig with widget: %v", err)
|
||||
}
|
||||
|
||||
// 更新:显式禁用(布尔零值不能被省略)
|
||||
if err := s.UpdateWidget(w.ID, &model.SidebarWidget{Name: "跑马灯2", Html: "<div>hi2</div>", Js: "console.log(1)", Enabled: false}); err != nil {
|
||||
t.Fatalf("UpdateWidget: %v", err)
|
||||
}
|
||||
var after model.SidebarWidget
|
||||
if err := db.First(&after, w.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if after.Enabled || after.Name != "跑马灯2" || after.Js == "" {
|
||||
t.Fatalf("禁用/字段更新未生效: %+v", after)
|
||||
}
|
||||
|
||||
// 禁用后不出现在公开载荷
|
||||
payload, err := s.PublicPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("PublicPayload: %v", err)
|
||||
}
|
||||
if len(payload.Widgets) != 0 {
|
||||
t.Fatal("禁用的工具不应出现在公开载荷")
|
||||
}
|
||||
if err := s.SetWidgetEnabled(w.ID, true); err != nil {
|
||||
t.Fatalf("SetWidgetEnabled: %v", err)
|
||||
}
|
||||
payload, _ = s.PublicPayload()
|
||||
if len(payload.Widgets) != 1 || payload.Widgets[0].Html != "<div>hi2</div>" {
|
||||
t.Fatalf("启用后应出现在公开载荷: %+v", payload.Widgets)
|
||||
}
|
||||
|
||||
// 删除工具 → 配置中的引用被清理
|
||||
if err := s.DeleteWidget(w.ID); err != nil {
|
||||
t.Fatalf("DeleteWidget: %v", err)
|
||||
}
|
||||
cfg, _ := s.GetConfig()
|
||||
for _, r := range *cfg.Scopes["home"].Right {
|
||||
if r.WidgetID == w.ID {
|
||||
t.Fatal("删除后配置中不应残留引用")
|
||||
}
|
||||
}
|
||||
// 重复删除 → NotFound
|
||||
if err := s.DeleteWidget(w.ID); !errors.Is(err, ErrSidebarWidgetNotFound) {
|
||||
t.Fatal("重复删除应返回 NotFound")
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
vidUUIDRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
)
|
||||
|
||||
// TelemetryService 轻量页面访问埋点
|
||||
type TelemetryService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTelemetryService(db *gorm.DB) *TelemetryService {
|
||||
return &TelemetryService{db: db}
|
||||
}
|
||||
|
||||
func todayDate() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
func hashVid(vid string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToLower(vid))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ValidVisitorID 校验浏览器访客 ID(UUID)
|
||||
func ValidVisitorID(vid string) bool {
|
||||
return vidUUIDRe.MatchString(strings.TrimSpace(vid))
|
||||
}
|
||||
|
||||
// RecordPageView 记录一次页面浏览:PV 必增;合法 vid 首次出现当日才 UV+1
|
||||
func (s *TelemetryService) RecordPageView(vid string, loggedIn bool) error {
|
||||
day := todayDate()
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var st model.SiteDailyStats
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("date = ?", day).First(&st).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
st = model.SiteDailyStats{Date: day}
|
||||
if err := tx.Create(&st).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 重新锁住新建行
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("date = ?", day).First(&st).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"pv": st.PV + 1,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if loggedIn {
|
||||
updates["logged_in_pv"] = st.LoggedInPV + 1
|
||||
}
|
||||
|
||||
if ValidVisitorID(vid) {
|
||||
vis := model.SiteDailyVisitor{Date: day, VidHash: hashVid(vid)}
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&vis)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
updates["uv"] = st.UV + 1
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Model(&model.SiteDailyStats{}).Where("date = ?", day).Updates(updates).Error
|
||||
})
|
||||
return err
|
||||
}
|
||||
69
backend/service/visit_bot.go
Normal file
69
backend/service/visit_bot.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
// botSignatures 爬虫 UA 特征(小写子串 → 展示名),覆盖搜索引擎 / SEO 工具 / AI 爬虫 / 命令行客户端
|
||||
var botSignatures = []struct {
|
||||
token string
|
||||
name string
|
||||
}{
|
||||
{"googlebot", "Googlebot"},
|
||||
{"google-inspectiontool", "Google Inspection Tool"},
|
||||
{"apis-google", "Googlebot"},
|
||||
{"bingbot", "bingbot"},
|
||||
{"baiduspider", "Baiduspider"},
|
||||
{"sogou", "Sogou spider"},
|
||||
{"yisou", "YisouSpider"},
|
||||
{"360spider", "360Spider"},
|
||||
{"haosouspider", "HaosouSpider"},
|
||||
{"bytespider", "Bytespider"},
|
||||
{"duckduckbot", "DuckDuckBot"},
|
||||
{"applebot", "Applebot"},
|
||||
{"petalbot", "PetalBot"},
|
||||
{"yandexbot", "YandexBot"},
|
||||
{"semrushbot", "SemrushBot"},
|
||||
{"ahrefsbot", "AhrefsBot"},
|
||||
{"mj12bot", "MJ12bot"},
|
||||
{"dotbot", "DotBot"},
|
||||
{"gptbot", "GPTBot"},
|
||||
{"claudebot", "ClaudeBot"},
|
||||
{"claude-web", "ClaudeBot"},
|
||||
{"ccbot", "CCBot"},
|
||||
{"perplexitybot", "PerplexityBot"},
|
||||
{"amazonbot", "Amazonbot"},
|
||||
{"facebookexternalhit", "facebookexternalhit"},
|
||||
{"twitterbot", "Twitterbot"},
|
||||
{"slackbot", "Slackbot"},
|
||||
{"discordbot", "Discordbot"},
|
||||
{"linkedinbot", "LinkedInBot"},
|
||||
{"curl/", "curl"},
|
||||
{"wget", "Wget"},
|
||||
{"python-requests", "python-requests"},
|
||||
{"python-urllib", "python-urllib"},
|
||||
{"httpclient", "HTTP Client"},
|
||||
{"go-http-client", "Go-http-client"},
|
||||
{"java/", "Java Client"},
|
||||
{"okhttp", "OkHttp"},
|
||||
{"scrapy", "Scrapy"},
|
||||
{"headlesschrome", "HeadlessChrome"},
|
||||
{"puppeteer", "Puppeteer"},
|
||||
{"phantomjs", "PhantomJS"},
|
||||
{"bot;", "Bot"},
|
||||
{"spider", "Spider"},
|
||||
{"crawler", "Crawler"},
|
||||
}
|
||||
|
||||
// ClassifyBot 根据 User-Agent 判定爬虫;命中返回爬虫展示名,否则返回空串。
|
||||
// 纯小写包含匹配,便于单测。
|
||||
func ClassifyBot(ua string) string {
|
||||
low := strings.ToLower(strings.TrimSpace(ua))
|
||||
if low == "" {
|
||||
return ""
|
||||
}
|
||||
for _, sig := range botSignatures {
|
||||
if strings.Contains(low, sig.token) {
|
||||
return sig.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
31
backend/service/visit_bot_test.go
Normal file
31
backend/service/visit_bot_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassifyBot(t *testing.T) {
|
||||
cases := []struct {
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "Googlebot"},
|
||||
{"Mozilla/5.0 (compatible; bingbot/2.0)", "bingbot"},
|
||||
{"Mozilla/5.0 (compatible; Baiduspider/2.0)", "Baiduspider"},
|
||||
{"Mozilla/5.0 (compatible; Bytespider; https://zhanzhang.toutiao.com/)", "Bytespider"},
|
||||
{"curl/8.4.0", "curl"},
|
||||
{"Wget/1.21", "Wget"},
|
||||
{"python-requests/2.31", "python-requests"},
|
||||
{"Go-http-client/2.0", "Go-http-client"},
|
||||
{"GPTBot/1.0", "GPTBot"},
|
||||
{"ClaudeBot/1.0", "ClaudeBot"},
|
||||
{"HeadlessChrome/120", "HeadlessChrome"},
|
||||
// 人类浏览器不命中
|
||||
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", ""},
|
||||
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyBot(c.ua); got != c.want {
|
||||
t.Errorf("ClassifyBot(%q) = %q, want %q", c.ua, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
backend/service/visit_ref.go
Normal file
63
backend/service/visit_ref.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 来源分类常量
|
||||
const (
|
||||
RefKindDirect = "direct"
|
||||
RefKindInternal = "internal"
|
||||
RefKindSearch = "search"
|
||||
RefKindSocial = "social"
|
||||
RefKindExternal = "external"
|
||||
)
|
||||
|
||||
// searchHosts 搜索引擎域名(按「等于或以 .子域结尾」匹配)
|
||||
var searchHosts = []string{"google.com", "bing.com", "baidu.com", "sogou.com", "so.com", "sm.cn", "duckduckgo.com", "yandex.com", "yandex.ru", "yahoo.com"}
|
||||
|
||||
// socialHosts 社交/社区域名特征
|
||||
var socialHosts = []string{
|
||||
"weibo.com", "weibo.cn", "zhihu.com", "zhuanlan.zhihu.com", "twitter.com", "x.com",
|
||||
"t.me", "telegram.me", "douyin.com", "xiaohongshu.com", "bilibili.com", "b23.tv",
|
||||
"qq.com", "tieba.baidu.com", "v2ex.com", "juejin.cn", "csdn.net", "segmentfault.com",
|
||||
"github.com", "gitee.com", "reddit.com", "facebook.com", "instagram.com", "discord.com",
|
||||
}
|
||||
|
||||
// hostMatches 按「等于或以 .子域结尾」匹配 host 与域名列表
|
||||
func hostMatches(host string, list []string) bool {
|
||||
for _, d := range list {
|
||||
if host == d || strings.HasSuffix(host, "."+d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClassifyReferrer 解析并归类 Referer:返回 (来源 host, 来源类别)。
|
||||
// 空 → direct;本站 → internal;搜索引擎 → search;社交 → social;其余 → external;解析失败按 direct。
|
||||
func ClassifyReferrer(raw, siteHost string) (string, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", RefKindDirect
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", RefKindDirect
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
if i := strings.LastIndex(host, ":"); i >= 0 && !strings.Contains(host, "]") {
|
||||
host = host[:i] // 去端口
|
||||
}
|
||||
if siteHost != "" && (host == siteHost || siteHost == host) {
|
||||
return host, RefKindInternal
|
||||
}
|
||||
if hostMatches(host, searchHosts) {
|
||||
return host, RefKindSearch
|
||||
}
|
||||
if hostMatches(host, socialHosts) {
|
||||
return host, RefKindSocial
|
||||
}
|
||||
return host, RefKindExternal
|
||||
}
|
||||
31
backend/service/visit_ref_test.go
Normal file
31
backend/service/visit_ref_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassifyReferrer(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, siteHost string
|
||||
wantHost string
|
||||
wantKind string
|
||||
}{
|
||||
{"", "example.com", "", RefKindDirect},
|
||||
{"not a url", "example.com", "", RefKindDirect},
|
||||
{"https://example.com/posts/1", "example.com", "example.com", RefKindInternal},
|
||||
{"https://www.google.com/search?q=x", "example.com", "www.google.com", RefKindSearch},
|
||||
{"https://www.baidu.com/s?wd=x", "example.com", "www.baidu.com", RefKindSearch},
|
||||
{"https://cn.bing.com/search?q=x", "example.com", "cn.bing.com", RefKindSearch},
|
||||
{"https://weibo.com/abc", "example.com", "weibo.com", RefKindSocial},
|
||||
{"https://x.com/user/status/1", "example.com", "x.com", RefKindSocial},
|
||||
{"https://v2ex.com/t/123", "example.com", "v2ex.com", RefKindSocial},
|
||||
{"https://blog.other.org/post", "example.com", "blog.other.org", RefKindExternal},
|
||||
{"https://google.com:443/search?q=x", "example.com", "google.com", RefKindSearch},
|
||||
{"https://evil.com/?u=https://google.com", "example.com", "evil.com", RefKindExternal},
|
||||
}
|
||||
for _, c := range cases {
|
||||
host, kind := ClassifyReferrer(c.raw, c.siteHost)
|
||||
if host != c.wantHost || kind != c.wantKind {
|
||||
t.Errorf("ClassifyReferrer(%q, %q) = (%q, %q), want (%q, %q)",
|
||||
c.raw, c.siteHost, host, kind, c.wantHost, c.wantKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
295
backend/service/visitstats.go
Normal file
295
backend/service/visitstats.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 访问统计管道参数
|
||||
const (
|
||||
visitChannelSize = 8192 // 事件缓冲:满则丢弃(统计场景可接受)
|
||||
visitFlushBatch = 500 // 攒批上限:满批立即落库
|
||||
visitFlushInterval = 3 * time.Second // 或每 3 秒落库一次
|
||||
visitDeleteBatch = 5000 // 保留期清理分批大小,避免长事务
|
||||
)
|
||||
|
||||
// VisitorCookieName 浏览器访客 ID cookie 名(handler 签发、捕获中间件读取)
|
||||
const VisitorCookieName = "j13_vid"
|
||||
|
||||
var vidUUIDRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
// todayDate 当天零点(本地时区)
|
||||
func todayDate() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// HashVisitorID 访客 ID 仅存 SHA-256 哈希(明细不含原始 vid)
|
||||
func HashVisitorID(vid string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToLower(vid))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ValidVisitorID 校验浏览器访客 ID(UUID 形态)
|
||||
func ValidVisitorID(vid string) bool {
|
||||
return vidUUIDRe.MatchString(strings.TrimSpace(vid))
|
||||
}
|
||||
|
||||
// VisitStatsService 访问统计:内存攒批落库 + 保留期滚动清理 + 管理端聚合查询
|
||||
type VisitStatsService struct {
|
||||
db *gorm.DB
|
||||
setting *SettingService
|
||||
ch chan model.VisitEvent
|
||||
dropped atomic.Uint64
|
||||
flushOnce sync.Once
|
||||
}
|
||||
|
||||
func NewVisitStatsService(db *gorm.DB, settingSvc *SettingService) *VisitStatsService {
|
||||
return &VisitStatsService{
|
||||
db: db,
|
||||
setting: settingSvc,
|
||||
ch: make(chan model.VisitEvent, visitChannelSize),
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue 非阻塞投递事件;缓冲满时丢弃并计数
|
||||
func (s *VisitStatsService) Enqueue(ev model.VisitEvent) {
|
||||
if ev.CreatedAt.IsZero() {
|
||||
ev.CreatedAt = time.Now()
|
||||
}
|
||||
select {
|
||||
case s.ch <- ev:
|
||||
default:
|
||||
s.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// DroppedCount 已因缓冲满而丢弃的事件数(诊断用)
|
||||
func (s *VisitStatsService) DroppedCount() uint64 { return s.dropped.Load() }
|
||||
|
||||
// pvDelta 一天内某批 pageview 事件的 PV/UV 增量
|
||||
type pvDelta struct {
|
||||
PV int64
|
||||
LoggedInPV int64
|
||||
VidHashes map[string]struct{}
|
||||
}
|
||||
|
||||
// summarizePageviews 批内按 (日期, vid) 归并 PV/UV 增量;bot/probe 不计。纯函数,便于单测。
|
||||
func summarizePageviews(events []model.VisitEvent) map[time.Time]*pvDelta {
|
||||
out := make(map[time.Time]*pvDelta, 2)
|
||||
for _, ev := range events {
|
||||
if ev.Kind != model.VisitKindPageview {
|
||||
continue
|
||||
}
|
||||
// 取事件本地日期(不能用 Truncate:它按 UTC 绝对时间截断,非零时区会错位)
|
||||
t := ev.CreatedAt.In(time.Local)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||
d, ok := out[day]
|
||||
if !ok {
|
||||
d = &pvDelta{VidHashes: make(map[string]struct{})}
|
||||
out[day] = d
|
||||
}
|
||||
d.PV++
|
||||
if ev.UserID > 0 {
|
||||
d.LoggedInPV++
|
||||
}
|
||||
if ev.VidHash != "" {
|
||||
d.VidHashes[ev.VidHash] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flush 落库一批事件:明细批量 INSERT + 同一事务合并日统计(PV 必增、当日 vid 首次 UV+1)。
|
||||
// bot/probe 只写明细。失败记日志不重试(统计允许少量丢失)。
|
||||
func (s *VisitStatsService) flush(events []model.VisitEvent) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
if err := s.db.CreateInBatches(&events, visitFlushBatch).Error; err != nil {
|
||||
log.Printf("[visitstats] 明细落库失败(%d 条丢弃): %v", len(events), err)
|
||||
return
|
||||
}
|
||||
|
||||
deltas := summarizePageviews(events)
|
||||
if len(deltas) == 0 {
|
||||
return
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for day, d := range deltas {
|
||||
// 当日访客去重:插入成功(此前未出现)才计 UV
|
||||
uv := int64(0)
|
||||
for vid := range d.VidHashes {
|
||||
vis := model.SiteDailyVisitor{Date: day, VidHash: vid}
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&vis)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
uv += res.RowsAffected
|
||||
}
|
||||
// 日统计 upsert:行不存在自动创建,存在则累加
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO site_daily_stats (date, pv, uv, logged_in_pv, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (date) DO UPDATE SET
|
||||
pv = site_daily_stats.pv + EXCLUDED.pv,
|
||||
uv = site_daily_stats.uv + EXCLUDED.uv,
|
||||
logged_in_pv = site_daily_stats.logged_in_pv + EXCLUDED.logged_in_pv,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, day, d.PV, uv, d.LoggedInPV, time.Now()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[visitstats] 日统计合并失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// StartFlusher 攒批落库协程:满 500 条或每 3 秒触发;ctx 取消时冲刷余量后退出
|
||||
func (s *VisitStatsService) StartFlusher(ctx context.Context) {
|
||||
s.flushOnce.Do(func() {
|
||||
ticker := time.NewTicker(visitFlushInterval)
|
||||
defer ticker.Stop()
|
||||
buf := make([]model.VisitEvent, 0, visitFlushBatch)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.drain(buf)
|
||||
return
|
||||
case ev := <-s.ch:
|
||||
buf = append(buf, ev)
|
||||
if len(buf) >= visitFlushBatch {
|
||||
s.flush(buf)
|
||||
buf = make([]model.VisitEvent, 0, visitFlushBatch)
|
||||
}
|
||||
case <-ticker.C:
|
||||
if len(buf) > 0 {
|
||||
s.flush(buf)
|
||||
buf = make([]model.VisitEvent, 0, visitFlushBatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// drain 退出前尽量冲刷缓冲中的余量(非阻塞)
|
||||
func (s *VisitStatsService) drain(buf []model.VisitEvent) {
|
||||
for {
|
||||
select {
|
||||
case ev := <-s.ch:
|
||||
buf = append(buf, ev)
|
||||
if len(buf) >= visitFlushBatch {
|
||||
s.flush(buf)
|
||||
buf = make([]model.VisitEvent, 0, visitFlushBatch)
|
||||
}
|
||||
default:
|
||||
s.flush(buf)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retentionDays 当前保留天数(缺行/非法回退默认 90)
|
||||
func (s *VisitStatsService) retentionDays() int {
|
||||
days, err := s.setting.AnalyticsRetentionDays()
|
||||
if err != nil || days <= 0 {
|
||||
return DefaultAnalyticsRetentionDays
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// deleteOlderThan 分批删除截止时间前的明细,返回删除总数
|
||||
func (s *VisitStatsService) deleteOlderThan(cutoff time.Time) int64 {
|
||||
var total int64
|
||||
for {
|
||||
res := s.db.Exec(
|
||||
`DELETE FROM visit_events WHERE id IN (
|
||||
SELECT id FROM visit_events WHERE created_at < ? LIMIT ?
|
||||
)`, cutoff, visitDeleteBatch)
|
||||
if res.Error != nil {
|
||||
log.Printf("[visitstats] 保留期清理失败: %v", res.Error)
|
||||
return total
|
||||
}
|
||||
total += res.RowsAffected
|
||||
if res.RowsAffected < visitDeleteBatch {
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunRetention 保留期滚动清理:每天 04:30 低峰触发
|
||||
func (s *VisitStatsService) RunRetention(ctx context.Context) {
|
||||
for {
|
||||
now := time.Now()
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), 4, 30, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(next.Sub(now)):
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -s.retentionDays())
|
||||
if n := s.deleteOlderThan(cutoff); n > 0 {
|
||||
log.Printf("[visitstats] 保留期清理 %d 条(> %s)", n, cutoff.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PurgeBefore 手动清理 N 天前明细:goroutine 异步分批执行,受理即返回(前端稍后刷新容量)。
|
||||
func (s *VisitStatsService) PurgeBefore(days int) bool {
|
||||
if days < MinAnalyticsRetentionDays {
|
||||
return false
|
||||
}
|
||||
if days > MaxAnalyticsRetentionDays {
|
||||
days = MaxAnalyticsRetentionDays
|
||||
}
|
||||
go func() {
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
if n := s.deleteOlderThan(cutoff); n > 0 {
|
||||
log.Printf("[visitstats] 手动清理 %d 条(> %s)", n, cutoff.Format("2006-01-02"))
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// VisitUsage 明细表容量(行数 + 磁盘占用字节)
|
||||
type VisitUsage struct {
|
||||
Rows int64 `json:"rows"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Usage 统计明细行数与磁盘占用(含索引/TOAST)
|
||||
func (s *VisitStatsService) Usage() (VisitUsage, error) {
|
||||
var u VisitUsage
|
||||
if err := s.db.Model(&model.VisitEvent{}).Count(&u.Rows).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
if err := s.db.Raw(`SELECT pg_total_relation_size('visit_events')`).Scan(&u.Bytes).Error; err != nil {
|
||||
return u, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Enabled 采集开关(缺行视为开启)
|
||||
func (s *VisitStatsService) Enabled() bool {
|
||||
on, err := s.setting.AnalyticsEnabled()
|
||||
return err == nil && on
|
||||
}
|
||||
|
||||
// CanonicalIP 归一化客户端 IP(导出供 handler/middleware 使用)
|
||||
func CanonicalIP(ip string) string { return canonicalIP(ip) }
|
||||
616
backend/service/visitstats_query.go
Normal file
616
backend/service/visitstats_query.go
Normal file
@@ -0,0 +1,616 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ============ 管理端聚合查询(visit_events SQL 聚合;长趋势用 site_daily_stats) ============
|
||||
|
||||
// VisitDayPoint 日趋势点
|
||||
type VisitDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
PV int64 `json:"pv"`
|
||||
UV int64 `json:"uv"`
|
||||
}
|
||||
|
||||
// VisitNamedCount 命名计数(设备/来源/爬虫等)
|
||||
type VisitNamedCount struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// VisitPageRow 受访页面行
|
||||
type VisitPageRow struct {
|
||||
Path string `json:"path"`
|
||||
PV int64 `json:"pv"`
|
||||
UV int64 `json:"uv"`
|
||||
Share float64 `json:"share"` // 占区间总 PV 比例(0–100)
|
||||
}
|
||||
|
||||
// VisitOverview 概览 Tab
|
||||
type VisitOverview struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
Trend []VisitDayPoint `json:"trend"`
|
||||
TodayPV int64 `json:"today_pv"`
|
||||
TodayUV int64 `json:"today_uv"`
|
||||
YesterdayPV int64 `json:"yesterday_pv"`
|
||||
YesterdayUV int64 `json:"yesterday_uv"`
|
||||
LoggedInPV int64 `json:"logged_in_pv"` // 今日登录 PV
|
||||
Devices []VisitNamedCount `json:"devices"`
|
||||
Sources []VisitNamedCount `json:"sources"`
|
||||
TopPages []VisitPageRow `json:"top_pages"`
|
||||
}
|
||||
|
||||
// VisitMinutePoint 分钟级 PV 点
|
||||
type VisitMinutePoint struct {
|
||||
Minute string `json:"minute"` // YYYY-MM-DD HH24:MI
|
||||
PV int64 `json:"pv"`
|
||||
}
|
||||
|
||||
// VisitEventItem 明细列表行
|
||||
type VisitEventItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
RefHost string `json:"ref_host"`
|
||||
RefKind string `json:"ref_kind"`
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
Device string `json:"device"`
|
||||
OS string `json:"os"`
|
||||
Browser string `json:"browser"`
|
||||
Bot string `json:"bot"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// VisitRealtime 实时 Tab
|
||||
type VisitRealtime struct {
|
||||
Minutes []VisitMinutePoint `json:"minutes"`
|
||||
PV5min int64 `json:"pv_5min"`
|
||||
PV30min int64 `json:"pv_30min"`
|
||||
Online int64 `json:"online"`
|
||||
TopPages []VisitNamedCount `json:"top_pages"`
|
||||
Recent []VisitEventItem `json:"recent"`
|
||||
}
|
||||
|
||||
// VisitSourcesKindDay 来源按日(kind 分列行)
|
||||
type VisitSourcesKindDay struct {
|
||||
Date string `json:"date"`
|
||||
Items []VisitNamedCount `json:"items"`
|
||||
}
|
||||
|
||||
// VisitSources 来源分析 Tab
|
||||
type VisitSources struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
Kinds []VisitNamedCount `json:"kinds"`
|
||||
SearchHost []VisitNamedCount `json:"search_hosts"`
|
||||
External []VisitNamedCount `json:"external"`
|
||||
Trend []VisitSourcesKindDay `json:"trend"`
|
||||
}
|
||||
|
||||
// VisitPages 受访页面 Tab
|
||||
type VisitPages struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
Total int64 `json:"total"` // 命中的 path 数
|
||||
Items []VisitPageRow `json:"items"`
|
||||
}
|
||||
|
||||
// VisitVisitors 访客画像 Tab
|
||||
type VisitVisitors struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
Devices []VisitNamedCount `json:"devices"`
|
||||
OSes []VisitNamedCount `json:"oses"`
|
||||
Browsers []VisitNamedCount `json:"browsers"`
|
||||
LoggedInPV int64 `json:"logged_in_pv"`
|
||||
GuestPV int64 `json:"guest_pv"`
|
||||
NewVisitors int64 `json:"new_visitors"`
|
||||
Returning int64 `json:"returning"`
|
||||
Hourly []VisitNamedCount `json:"hourly"` // 0–23 时段 PV
|
||||
}
|
||||
|
||||
// VisitSecurity 爬虫与安全 Tab
|
||||
type VisitSecurity struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
Bots []VisitNamedCount `json:"bots"`
|
||||
BotTrend []VisitDayPoint `json:"bot_trend"`
|
||||
ProbeStatus []VisitNamedCount `json:"probe_status"`
|
||||
ProbeIPs []VisitNamedCount `json:"probe_ips"`
|
||||
LoginFailed int64 `json:"login_failed"` // 近 N 日登录失败次数
|
||||
FailedUsers int64 `json:"failed_users"` // 涉及用户名数
|
||||
}
|
||||
|
||||
// VisitEventFilter 明细筛选
|
||||
type VisitEventFilter struct {
|
||||
Kind string
|
||||
Bot string
|
||||
Path string
|
||||
IP string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
// VisitEventsResult 明细分页结果
|
||||
type VisitEventsResult struct {
|
||||
Total int64 `json:"total"`
|
||||
Items []VisitEventItem `json:"items"`
|
||||
}
|
||||
|
||||
// VisitSettingsPayload 设置 Tab(开关 + 保留天数 + 容量)
|
||||
type VisitSettingsPayload struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
Usage VisitUsage `json:"usage"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
}
|
||||
|
||||
type pvUVRow struct {
|
||||
PV int64 `gorm:"column:pv"`
|
||||
UV int64 `gorm:"column:uv"`
|
||||
}
|
||||
|
||||
func visitRangeStart(days int) time.Time {
|
||||
return todayDate().AddDate(0, 0, -(days - 1))
|
||||
}
|
||||
|
||||
// groupCounts 通用 GROUP BY 计数(expr 为聚合表达式),按计数降序取前 limit 条。
|
||||
// extraSQL 追加到 WHERE(如 " AND ref_kind = 'search'")。
|
||||
func (s *VisitStatsService) groupCounts(start time.Time, expr, kind, extraSQL string, limit int) ([]VisitNamedCount, error) {
|
||||
var rows []struct {
|
||||
Key string
|
||||
Count int64
|
||||
}
|
||||
q := s.db.Model(&model.VisitEvent{}).
|
||||
Select(expr+" AS key, COUNT(*) AS count").
|
||||
Where("created_at >= ?", start)
|
||||
if kind != "" {
|
||||
q = q.Where("kind = ?", kind)
|
||||
}
|
||||
if extraSQL != "" {
|
||||
q = q.Where(extraSQL)
|
||||
}
|
||||
if err := q.Group("key").Order("count DESC").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]VisitNamedCount, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, VisitNamedCount{Key: r.Key, Label: r.Key, Count: r.Count})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Overview 概览:趋势(site_daily_stats)+ 今日/昨日 + 设备/来源/热门页面
|
||||
func (s *VisitStatsService) Overview(rangeQ string) (*VisitOverview, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
start := visitRangeStart(days)
|
||||
out := &VisitOverview{RangeDays: days}
|
||||
|
||||
// 日趋势:日聚合表(每天一行,不随明细清理丢失)
|
||||
if err := s.db.Model(&model.SiteDailyStats{}).
|
||||
Select("to_char(date, 'YYYY-MM-DD') AS date, pv, uv").
|
||||
Where("date >= ?", start).
|
||||
Order("date ASC").Scan(&out.Trend).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空结果时 GORM 不赋值,nil 切片会序列化成 JSON null(前端期望 [])
|
||||
if out.Trend == nil {
|
||||
out.Trend = []VisitDayPoint{}
|
||||
}
|
||||
|
||||
var today pvUVRow
|
||||
if err := s.db.Model(&model.SiteDailyStats{}).
|
||||
Select("COALESCE(SUM(pv),0) AS pv, COALESCE(SUM(uv),0) AS uv").
|
||||
Where("date = ?", todayDate()).Scan(&today).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.TodayPV, out.TodayUV = today.PV, today.UV
|
||||
|
||||
var yesterday pvUVRow
|
||||
if err := s.db.Model(&model.SiteDailyStats{}).
|
||||
Select("COALESCE(SUM(pv),0) AS pv, COALESCE(SUM(uv),0) AS uv").
|
||||
Where("date = ?", todayDate().AddDate(0, 0, -1)).Scan(&yesterday).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.YesterdayPV, out.YesterdayUV = yesterday.PV, yesterday.UV
|
||||
|
||||
if err := s.db.Model(&model.SiteDailyStats{}).
|
||||
Select("COALESCE(SUM(logged_in_pv),0) AS logged_in_pv").
|
||||
Where("date = ?", todayDate()).Scan(&out.LoggedInPV).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var err error
|
||||
if out.Devices, err = s.groupCounts(start, "device", model.VisitKindPageview, "", 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Sources, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.TopPages, err = s.pageAgg(start, "", 1, 10); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pageAgg path 聚合 PV/UV/占比(q 为 path 模糊匹配;page 从 1 起)
|
||||
func (s *VisitStatsService) pageAgg(start time.Time, q string, page, size int) ([]VisitPageRow, error) {
|
||||
var totalPV int64
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Scopes(pathFilter(q)).
|
||||
Count(&totalPV).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if totalPV == 0 {
|
||||
return []VisitPageRow{}, nil
|
||||
}
|
||||
|
||||
var rows []VisitPageRow
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("path, COUNT(*) AS pv, COUNT(DISTINCT vid_hash) AS uv").
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Scopes(pathFilter(q)).
|
||||
Group("path").Order("pv DESC").
|
||||
Offset((page - 1) * size).Limit(size).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].Share = float64(rows[i].PV) * 100 / float64(totalPV)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func pathFilter(q string) func(*gorm.DB) *gorm.DB {
|
||||
if q == "" {
|
||||
return func(db *gorm.DB) *gorm.DB { return db }
|
||||
}
|
||||
return func(db *gorm.DB) *gorm.DB { return db.Where("path ILIKE ?", "%"+q+"%") }
|
||||
}
|
||||
|
||||
// Realtime 实时:近 60 分钟分钟级 PV + 在线 + 近 5 分钟热门路径 + 最新 20 条
|
||||
func (s *VisitStatsService) Realtime() (*VisitRealtime, error) {
|
||||
out := &VisitRealtime{}
|
||||
now := time.Now()
|
||||
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("to_char(created_at, 'YYYY-MM-DD HH24:MI') AS minute, COUNT(*) AS pv").
|
||||
Where("created_at >= ? AND kind = ?", now.Add(-time.Hour), model.VisitKindPageview).
|
||||
Group("minute").Order("minute ASC").Scan(&out.Minutes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Minutes == nil {
|
||||
out.Minutes = []VisitMinutePoint{}
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Where("created_at >= ? AND kind = ?", now.Add(-5*time.Minute), model.VisitKindPageview).
|
||||
Count(&out.PV5min).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Where("created_at >= ? AND kind = ?", now.Add(-30*time.Minute), model.VisitKindPageview).
|
||||
Count(&out.PV30min).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 在线口径与首页概览一致:users.last_seen_at 近 5 分钟且未封禁
|
||||
if err := s.db.Model(&model.User{}).
|
||||
Where("last_seen_at IS NOT NULL AND last_seen_at >= ? AND banned = ?", now.Add(-5*time.Minute), false).
|
||||
Count(&out.Online).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("path AS key, COUNT(*) AS count").
|
||||
Where("created_at >= ? AND kind = ?", now.Add(-5*time.Minute), model.VisitKindPageview).
|
||||
Group("path").Order("count DESC").Limit(10).
|
||||
Scan(&out.TopPages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out.TopPages {
|
||||
out.TopPages[i].Label = out.TopPages[i].Key
|
||||
}
|
||||
if out.TopPages == nil {
|
||||
out.TopPages = []VisitNamedCount{}
|
||||
}
|
||||
|
||||
res, err := s.Events(VisitEventFilter{Kind: model.VisitKindPageview, Page: 1, Size: 20})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Recent = res.Items
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Sources 来源分析
|
||||
func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
start := visitRangeStart(days)
|
||||
out := &VisitSources{RangeDays: days}
|
||||
var err error
|
||||
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
||||
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
||||
"ref_kind = '"+RefKindExternal+"'", 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Trend, err = s.SourcesTrend(rangeQ); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SourcesTrend 来源按日明细(kind 分列行)
|
||||
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
start := visitRangeStart(days)
|
||||
var rows []struct {
|
||||
Date string
|
||||
RefKind string
|
||||
Count int64
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("to_char(created_at, 'YYYY-MM-DD') AS date, ref_kind, COUNT(*) AS count").
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Group("date, ref_kind").Order("date ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byDay := make(map[string][]VisitNamedCount)
|
||||
var dates []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, r := range rows {
|
||||
if _, ok := seen[r.Date]; !ok {
|
||||
seen[r.Date] = struct{}{}
|
||||
dates = append(dates, r.Date)
|
||||
}
|
||||
byDay[r.Date] = append(byDay[r.Date], VisitNamedCount{Key: r.RefKind, Label: r.RefKind, Count: r.Count})
|
||||
}
|
||||
out := make([]VisitSourcesKindDay, 0, len(dates))
|
||||
for _, d := range dates {
|
||||
out = append(out, VisitSourcesKindDay{Date: d, Items: byDay[d]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Pages 受访页面(分页 + 模糊)
|
||||
func (s *VisitStatsService) Pages(rangeQ, q string, page, size int) (*VisitPages, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
start := visitRangeStart(days)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
out := &VisitPages{RangeDays: days}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Scopes(pathFilter(q)).
|
||||
Distinct("path").Count(&out.Total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.pageAgg(start, q, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []VisitPageRow{}
|
||||
}
|
||||
out.Items = items
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Visitors 访客画像
|
||||
func (s *VisitStatsService) Visitors(rangeQ string) (*VisitVisitors, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
start := visitRangeStart(days)
|
||||
out := &VisitVisitors{RangeDays: days}
|
||||
var err error
|
||||
if out.Devices, err = s.groupCounts(start, "device", model.VisitKindPageview, "", 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.OSes, err = s.groupCounts(start, "os", model.VisitKindPageview, "", 10); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Browsers, err = s.groupCounts(start, "browser", model.VisitKindPageview, "", 10); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lg pvUVRow
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("COALESCE(SUM(CASE WHEN user_id > 0 THEN 1 ELSE 0 END),0) AS pv, COALESCE(SUM(CASE WHEN user_id = 0 THEN 1 ELSE 0 END),0) AS uv").
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Scan(&lg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.LoggedInPV, out.GuestPV = lg.PV, lg.UV
|
||||
|
||||
// 新老访客:区间内 vid 首次出现时间晚于区间起点视为新访客
|
||||
var uvRows []struct {
|
||||
VidHash string
|
||||
FirstAt time.Time `gorm:"column:first_at"`
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("vid_hash, MIN(created_at) AS first_at").
|
||||
Where("created_at >= ? AND kind = ? AND vid_hash <> ''", start, model.VisitKindPageview).
|
||||
Group("vid_hash").Scan(&uvRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range uvRows {
|
||||
if r.FirstAt.After(start) {
|
||||
out.NewVisitors++
|
||||
} else {
|
||||
out.Returning++
|
||||
}
|
||||
}
|
||||
|
||||
// 24 小时时段 PV(区间合计)
|
||||
var hours []struct {
|
||||
Hour int
|
||||
Count int64
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("EXTRACT(HOUR FROM created_at)::int AS hour, COUNT(*) AS count").
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Group("hour").Order("hour ASC").Scan(&hours).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Hourly = make([]VisitNamedCount, 24)
|
||||
for i := range out.Hourly {
|
||||
label := strconv.Itoa(i)
|
||||
if i < 10 {
|
||||
label = "0" + label
|
||||
}
|
||||
out.Hourly[i].Key = label
|
||||
out.Hourly[i].Label = label + " 时"
|
||||
}
|
||||
for _, h := range hours {
|
||||
if h.Hour >= 0 && h.Hour < 24 {
|
||||
out.Hourly[h.Hour].Count = h.Count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Security 爬虫与安全
|
||||
func (s *VisitStatsService) Security(rangeQ string) (*VisitSecurity, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
start := visitRangeStart(days)
|
||||
out := &VisitSecurity{RangeDays: days}
|
||||
var err error
|
||||
if out.Bots, err = s.groupCounts(start, "bot", model.VisitKindBot, "", 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select("to_char(created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS pv").
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindBot).
|
||||
Group("date").Order("date ASC").Scan(&out.BotTrend).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.BotTrend == nil {
|
||||
out.BotTrend = []VisitDayPoint{}
|
||||
}
|
||||
if out.ProbeStatus, err = s.groupCounts(start, "status::text", model.VisitKindProbe, "", 15); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.ProbeIPs, err = s.groupCounts(start, "ip", model.VisitKindProbe, "", 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.db.Model(&model.LoginLog{}).
|
||||
Where("success = ? AND created_at >= ?", false, start).
|
||||
Count(&out.LoginFailed).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Raw(`SELECT COUNT(DISTINCT username) FROM login_logs WHERE success = ? AND created_at >= ?`,
|
||||
false, start).Scan(&out.FailedUsers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Events 访问明细(筛选 + 分页 + 补用户昵称)
|
||||
func (s *VisitStatsService) Events(f VisitEventFilter) (*VisitEventsResult, error) {
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
if f.Size < 1 || f.Size > 200 {
|
||||
f.Size = 50
|
||||
}
|
||||
apply := func(db *gorm.DB) *gorm.DB {
|
||||
if f.Kind != "" {
|
||||
db = db.Where("kind = ?", f.Kind)
|
||||
}
|
||||
if f.Bot != "" {
|
||||
db = db.Where("bot = ?", f.Bot)
|
||||
}
|
||||
if f.Path != "" {
|
||||
db = db.Where("path ILIKE ?", "%"+f.Path+"%")
|
||||
}
|
||||
if f.IP != "" {
|
||||
db = db.Where("ip = ?", f.IP)
|
||||
}
|
||||
if !f.Start.IsZero() {
|
||||
db = db.Where("created_at >= ?", f.Start)
|
||||
}
|
||||
if !f.End.IsZero() {
|
||||
db = db.Where("created_at < ?", f.End)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
out := &VisitEventsResult{}
|
||||
if err := apply(s.db.Model(&model.VisitEvent{})).Count(&out.Total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var events []model.VisitEvent
|
||||
if err := apply(s.db.Model(&model.VisitEvent{})).
|
||||
Order("id DESC").
|
||||
Offset((f.Page - 1) * f.Size).Limit(f.Size).
|
||||
Find(&events).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 批量补昵称
|
||||
userIDs := make([]uint, 0, len(events))
|
||||
for _, e := range events {
|
||||
if e.UserID > 0 {
|
||||
userIDs = append(userIDs, e.UserID)
|
||||
}
|
||||
}
|
||||
nameMap := make(map[uint]string, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var users []model.User
|
||||
if err := s.db.Select("id, username, nickname").
|
||||
Where("id IN ?", userIDs).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range users {
|
||||
nameMap[u.ID] = u.Nickname
|
||||
}
|
||||
}
|
||||
out.Items = make([]VisitEventItem, 0, len(events))
|
||||
for _, e := range events {
|
||||
out.Items = append(out.Items, VisitEventItem{
|
||||
ID: e.ID, CreatedAt: e.CreatedAt, Kind: e.Kind, Path: e.Path,
|
||||
RefHost: e.RefHost, RefKind: e.RefKind,
|
||||
UserID: e.UserID, Username: nameMap[e.UserID],
|
||||
IP: e.IP, Device: e.Device, OS: e.OS, Browser: e.Browser,
|
||||
Bot: e.Bot, Status: e.Status,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SettingsPayload 设置 Tab 数据
|
||||
func (s *VisitStatsService) SettingsPayload() (*VisitSettingsPayload, error) {
|
||||
out := &VisitSettingsPayload{
|
||||
Enabled: s.Enabled(),
|
||||
RetentionDays: s.retentionDays(),
|
||||
Dropped: s.DroppedCount(),
|
||||
}
|
||||
u, err := s.Usage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Usage = u
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveSettings 保存采集开关与保留天数
|
||||
func (s *VisitStatsService) SaveSettings(enabled bool, retentionDays int) error {
|
||||
if err := s.setting.SetAnalyticsEnabled(enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.setting.SetAnalyticsRetentionDays(retentionDays)
|
||||
}
|
||||
46
backend/service/visitstats_test.go
Normal file
46
backend/service/visitstats_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
func TestSummarizePageviews(t *testing.T) {
|
||||
day := time.Date(2026, 9, 24, 0, 0, 0, 0, time.Local)
|
||||
at := func(hour int) time.Time { return day.Add(time.Duration(hour) * time.Hour) }
|
||||
|
||||
events := []model.VisitEvent{
|
||||
{Kind: model.VisitKindPageview, CreatedAt: at(9), UserID: 1, VidHash: "aaa"},
|
||||
{Kind: model.VisitKindPageview, CreatedAt: at(9), VidHash: "aaa"}, // 同 vid 同日:去重
|
||||
{Kind: model.VisitKindPageview, CreatedAt: at(10), VidHash: "bbb"},
|
||||
{Kind: model.VisitKindBot, CreatedAt: at(9), VidHash: "aaa"}, // bot 不计
|
||||
{Kind: model.VisitKindProbe, CreatedAt: at(9), VidHash: "bbb"}, // probe 不计
|
||||
}
|
||||
out := summarizePageviews(events)
|
||||
d, ok := out[day]
|
||||
if !ok {
|
||||
t.Fatal("缺少当日聚合")
|
||||
}
|
||||
if d.PV != 3 {
|
||||
t.Errorf("PV = %d, want 3", d.PV)
|
||||
}
|
||||
if d.LoggedInPV != 1 {
|
||||
t.Errorf("LoggedInPV = %d, want 1", d.LoggedInPV)
|
||||
}
|
||||
if len(d.VidHashes) != 2 {
|
||||
t.Errorf("UV 候选 = %d, want 2", len(d.VidHashes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashVisitorID(t *testing.T) {
|
||||
a := HashVisitorID(" ABcd1234-0000-0000-0000-000000000000 ")
|
||||
b := HashVisitorID("abcd1234-0000-0000-0000-000000000000")
|
||||
if a != b {
|
||||
t.Error("大小写/空白应归一后哈希一致")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Errorf("哈希长度 = %d, want 64", len(a))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user