diff --git a/.env.example b/.env.example index f393d62..ffb1bd9 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,9 @@ SITE_URL=http://localhost:3000 SETTINGS_MASTER_KEY= # 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段 SERVICE_PRIVATE_HOSTS= -# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头 +# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头。 +# Docker Compose 部署示例:TRUSTED_PROXIES=172.16.0.0/12(bridge 网段,可用 docker network inspect 查看); +# 或精确填反代容器 IP;多值逗号分隔。经 Cloudflare 时改用 TRUSTED_PLATFORM=CF-Connecting-IP。 TRUSTED_PROXIES= # 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API MAINTENANCE_RECOVERY=0 diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 75c00b2..7a2fcdc 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -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 决定新帖/新评的初始状态: diff --git a/backend/handler/sidebar.go b/backend/handler/sidebar.go new file mode 100644 index 0000000..330e90d --- /dev/null +++ b/backend/handler/sidebar.go @@ -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}) +} diff --git a/backend/handler/telemetry.go b/backend/handler/telemetry.go index 992d923..17c0b20 100644 --- a/backend/handler/telemetry.go +++ b/backend/handler/telemetry.go @@ -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}) } diff --git a/backend/handler/visitstats.go b/backend/handler/visitstats.go new file mode 100644 index 0000000..08beb6c --- /dev/null +++ b/backend/handler/visitstats.go @@ -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}) +} diff --git a/backend/middleware/visit_capture.go b/backend/middleware/visit_capture.go new file mode 100644 index 0000000..29787d9 --- /dev/null +++ b/backend/middleware/visit_capture.go @@ -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) + } +} diff --git a/backend/model/db.go b/backend/model/db.go index bd929b7..20e1cdf 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -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) diff --git a/backend/model/models.go b/backend/model/models.go index 766489b..951f7ca 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -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) diff --git a/backend/router/router.go b/backend/router/router.go index f49d6ff..95891cb 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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)) diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index 9632904..dbdbdb5 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -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 } diff --git a/backend/service/setting.go b/backend/service/setting.go index 810ec11..9735aa1 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -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)) +} diff --git a/backend/service/sidebar.go b/backend/service/sidebar.go new file mode 100644 index 0000000..7c567ec --- /dev/null +++ b/backend/service/sidebar.go @@ -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 +} diff --git a/backend/service/sidebar_test.go b/backend/service/sidebar_test.go new file mode 100644 index 0000000..47864bc --- /dev/null +++ b/backend/service/sidebar_test.go @@ -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: "x"} + 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: "
hi
", 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: "
hi2
", 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 != "
hi2
" { + 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") + } +} diff --git a/backend/service/telemetry.go b/backend/service/telemetry.go deleted file mode 100644 index b32935c..0000000 --- a/backend/service/telemetry.go +++ /dev/null @@ -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 -} diff --git a/backend/service/visit_bot.go b/backend/service/visit_bot.go new file mode 100644 index 0000000..a994bf9 --- /dev/null +++ b/backend/service/visit_bot.go @@ -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 "" +} diff --git a/backend/service/visit_bot_test.go b/backend/service/visit_bot_test.go new file mode 100644 index 0000000..01780ff --- /dev/null +++ b/backend/service/visit_bot_test.go @@ -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) + } + } +} diff --git a/backend/service/visit_ref.go b/backend/service/visit_ref.go new file mode 100644 index 0000000..82313e4 --- /dev/null +++ b/backend/service/visit_ref.go @@ -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 +} diff --git a/backend/service/visit_ref_test.go b/backend/service/visit_ref_test.go new file mode 100644 index 0000000..75afafd --- /dev/null +++ b/backend/service/visit_ref_test.go @@ -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) + } + } +} diff --git a/backend/service/visitstats.go b/backend/service/visitstats.go new file mode 100644 index 0000000..2ad86c1 --- /dev/null +++ b/backend/service/visitstats.go @@ -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) } diff --git a/backend/service/visitstats_query.go b/backend/service/visitstats_query.go new file mode 100644 index 0000000..ca12e98 --- /dev/null +++ b/backend/service/visitstats_query.go @@ -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) +} diff --git a/backend/service/visitstats_test.go b/backend/service/visitstats_test.go new file mode 100644 index 0000000..6c3d15d --- /dev/null +++ b/backend/service/visitstats_test.go @@ -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)) + } +} diff --git a/deploy/.env.example b/deploy/.env.example index dd17aab..a735886 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -31,7 +31,9 @@ JWT_SECRET= SETTINGS_MASTER_KEY= # 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段 SERVICE_PRIVATE_HOSTS= -# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头 +# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头。 +# Compose 默认 bridge 网段在 172.16.0.0/12 内(docker network inspect 可查), +# 填 TRUSTED_PROXIES=172.16.0.0/12 即可让登录日志/访问统计记录真实访客 IP。 TRUSTED_PROXIES= # 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API MAINTENANCE_RECOVERY=0 diff --git a/docs/site-operations.md b/docs/site-operations.md index b9b41ff..4e6e3ea 100644 --- a/docs/site-operations.md +++ b/docs/site-operations.md @@ -104,6 +104,10 @@ 4. Go 与 Next 的 SITE_URL 必须一致且为正式外部 HTTPS origin;后台基本信息只读显示。Next 的 BACKEND_URL 指向可达 API,不使用请求 Host 生成邮件地址。 5. SERVICE_PRIVATE_HOSTS 为逗号分隔的精确主机名。默认拒绝私网、回环、链路本地及特殊地址,DNS 解析后连接已经检查的 IP;确有自建 SMTP/S3 时只放行对应主机。虚拟主机 S3 寻址时要按实际连接的 bucket.endpoint 主机配置。 6. TRUSTED_PROXIES 仅列出真正的代理 IP/CIDR。默认不信任转发头;不要直接填任意全网,避免登录/IP额度绕过。 + Compose 部署下不配置时 ClientIP 会记录为反代容器内网地址(如 172.19.0.4),登录日志与访问统计全部失真。 + 取真实 IP:先 `docker network inspect <网络名>` 查看子网(常见 172.18.0.0/16–172.31.0.0/16),再把该网段填入 + `TRUSTED_PROXIES`(如 `172.16.0.0/12` 覆盖全部 Docker bridge),反代需设置 `X-Forwarded-For`/`X-Real-IP`。 + 未配置时后端启动会输出警告日志便于排查。经 Cloudflare 等平台代理时改用 `TRUSTED_PLATFORM=CF-Connecting-IP`。 7. MAINTENANCE_RECOVERY=1 是部署级紧急恢复:重启 API 后强制正常访问;修复后台模式,再撤销该变量并重启。不会悄悄覆写数据库保存模式。 8. Compose 已透传四个新变量并保留原服务/端口/数据卷。参见 deploy/CHANGELOG.md。JWT、DB_DSN、Cookie安全属性仍保持部署级管理。 9. 反代/CDN不得缓存 HTML、/api/site-state、鉴权接口、503;停站和恢复后验证外部入口的真实状态码及缓存头。外部 CDN 未在本地测试范围内。 diff --git a/frontend/app/admin/analytics/AnalyticsShell.tsx b/frontend/app/admin/analytics/AnalyticsShell.tsx new file mode 100644 index 0000000..48afc34 --- /dev/null +++ b/frontend/app/admin/analytics/AnalyticsShell.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useRef, type KeyboardEvent, type ReactNode } from "react"; +import { BarChart3 } from "lucide-react"; +import { AdminPageHeader } from "@/components/admin"; + +export const ANALYTICS_TABS: { href: string; label: string }[] = [ + { href: "/admin/analytics", label: "概览" }, + { href: "/admin/analytics/realtime", label: "实时" }, + { href: "/admin/analytics/sources", label: "来源分析" }, + { href: "/admin/analytics/pages", label: "受访页面" }, + { href: "/admin/analytics/visitors", label: "访客画像" }, + { href: "/admin/analytics/security", label: "爬虫与安全" }, + { href: "/admin/analytics/details", label: "访问明细" }, + { href: "/admin/analytics/settings", label: "设置" }, +]; + +export default function AnalyticsShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const navRefs = useRef<(HTMLAnchorElement | null)[]>([]); + + // ←→/Home/End 键盘导航,范式同 SettingsShell(纵向改横向) + const onNavKeyDown = (e: KeyboardEvent, i: number) => { + if ( + e.key !== "ArrowRight" && + e.key !== "ArrowLeft" && + e.key !== "Home" && + e.key !== "End" + ) { + return; + } + e.preventDefault(); + let next = i; + if (e.key === "ArrowRight") next = (i + 1) % ANALYTICS_TABS.length; + else if (e.key === "ArrowLeft") next = (i + ANALYTICS_TABS.length - 1) % ANALYTICS_TABS.length; + else if (e.key === "Home") next = 0; + else next = ANALYTICS_TABS.length - 1; + navRefs.current[next]?.focus(); + router.push(ANALYTICS_TABS[next].href); + }; + + return ( +
+ + + {children} +
+ ); +} diff --git a/frontend/app/admin/analytics/OverviewClient.tsx b/frontend/app/admin/analytics/OverviewClient.tsx new file mode 100644 index 0000000..93c0213 --- /dev/null +++ b/frontend/app/admin/analytics/OverviewClient.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Eye, Users, LogIn, CalendarDays } from "lucide-react"; +import { AdminStatCard } from "@/components/admin"; +import { AdminChartCard, NamedBarChart, NamedPieChart, VisitTrendChart } from "@/components/admin/charts"; +import { apiStatsOverview, type VisitOverview, type VisitPageRow } from "@/lib/api"; +import { formatInt, RangeSegmented, StatsState, TableEmpty, deviceLabel, refKindLabel } from "./ui"; + +export default function OverviewClient({ initial }: { initial: VisitOverview | null }) { + const [range, setRange] = useState<"7d" | "30d">("7d"); + const [data, setData] = useState(initial); + const [loading, setLoading] = useState(!initial); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + setLoading(true); + apiStatsOverview(range) + .then((d) => { + if (alive) { + setData(d); + setError(""); + } + }) + .catch(() => alive && setError("加载失败,请稍后重试")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [range]); + + const delta = (today: number, yesterday: number) => { + if (yesterday <= 0) return today > 0 ? "+100%" : "—"; + const pct = Math.round(((today - yesterday) / yesterday) * 100); + return `${pct >= 0 ? "+" : ""}${pct}%`; + }; + + if (!data) return ; + + const devices = data.devices.map((d) => ({ label: deviceLabel(d.key), count: d.count })); + const sources = data.sources.map((d) => ({ label: refKindLabel(d.key), count: d.count })); + + return ( +
+
+

+ 今日 PV {formatInt(data.today_pv)} · UV {formatInt(data.today_uv)} +

+ +
+ + {error ? : null} + +
+ } + /> + } + color="var(--ok)" + soft="var(--ok-soft, var(--panel-2))" + /> + 0 + ? `占今日 PV ${Math.round((data.logged_in_pv / data.today_pv) * 100)}%` + : undefined + } + icon={} + color="var(--gold)" + soft="var(--gold-soft)" + /> + } + color="var(--ink-2)" + soft="var(--panel-2)" + /> +
+ + + + + +
+ + {devices.length ? : } + + + {sources.length ? : } + +
+ + + {data.top_pages.length ? ( +
+ {data.top_pages.map((p: VisitPageRow, i) => ( +
+ + {i + 1} + + + {p.path || "/"} + + + + + + {formatInt(p.pv)} + + + UV {formatInt(p.uv)} + +
+ ))} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/app/admin/analytics/details/page.tsx b/frontend/app/admin/analytics/details/page.tsx new file mode 100644 index 0000000..1d1023f --- /dev/null +++ b/frontend/app/admin/analytics/details/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { AdminPager, AdminSegmented } from "@/components/admin"; +import { AdminChartCard } from "@/components/admin/charts"; +import AdminSearchField from "@/components/admin/AdminSearchField"; +import { apiStatsEvents, type VisitEventItem, type VisitEventsResult } from "@/lib/api"; +import { StatsState, TableEmpty, deviceLabel, kindLabel, refKindLabel } from "../ui"; + +const PAGE_SIZE = 50; + +const KIND_OPTIONS = [ + { key: "", label: "全部" }, + { key: "pageview", label: "浏览" }, + { key: "bot", label: "爬虫" }, + { key: "probe", label: "探测" }, +]; + +/** 本地日期(yyyy-mm-dd)→ RFC3339,覆盖全天 */ +function dayToRFC3339(date: string, endOfDay = false): string { + if (!date) return ""; + return new Date(`${date}T${endOfDay ? "23:59:59" : "00:00:00"}`).toISOString(); +} + +function kindBadge(kind: string) { + const map: Record = { + pageview: { color: "var(--accent)", soft: "var(--accent-soft)" }, + bot: { color: "var(--gold)", soft: "var(--gold-soft)" }, + probe: { color: "var(--danger)", soft: "var(--danger-soft, var(--panel-2))" }, + }; + const s = map[kind] ?? { color: "var(--ink-3)", soft: "var(--panel-2)" }; + return ( + + {kindLabel(kind)} + + ); +} + +function DetailsInner() { + const searchParams = useSearchParams(); + // 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x) + const [initialIp] = useState(() => searchParams.get("ip") ?? ""); + + const [kind, setKind] = useState(""); + const [path, setPath] = useState(""); + const [ip, setIp] = useState(initialIp); + const [bot, setBot] = useState(""); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); + const [page, setPage] = useState(1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const load = useCallback( + (f: { kind: string; path: string; ip: string; bot: string; start: string; end: string }, p: number) => { + setLoading(true); + apiStatsEvents({ + kind: f.kind || undefined, + path: f.path || undefined, + ip: f.ip || undefined, + bot: f.bot || undefined, + start: dayToRFC3339(f.start) || undefined, + end: dayToRFC3339(f.end, true) || undefined, + page: p, + size: PAGE_SIZE, + }) + .then((d) => { + setData(d); + setError(""); + }) + .catch(() => setError("加载失败,请稍后重试")) + .finally(() => setLoading(false)); + }, + [] + ); + + useEffect(() => { + load({ kind, path, ip, bot, start, end }, page); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [load, kind, path, ip, bot, start, end, page]); + + const resetPage = () => setPage(1); + + return ( +
+

+ 记录含浏览、爬虫与异常请求;访客标识仅存哈希,IP 与登录日志口径一致,仅超管可见。 +

+ + +
+ { setKind(k); resetPage(); }} ariaLabel="事件类型筛选" role="group" size="sm" /> +
+
+ { setPath(v); }} + onSubmit={resetPage} + onClear={() => { setPath(""); resetPage(); }} + placeholder="路径模糊匹配,回车应用" + ariaLabel="按路径筛选" + /> +
+
+ { setIp(""); resetPage(); }} + placeholder="按 IP 筛选,回车应用" + ariaLabel="按 IP 筛选" + /> +
+
+ { setBot(""); resetPage(); }} + placeholder="按爬虫名筛选" + ariaLabel="按爬虫名筛选" + /> +
+ + +
+
+ + {error ? : null} + + {data && data.items.length ? ( + <> +
+ + + + + + + + + + + + + + + {data.items.map((e: VisitEventItem) => ( + + + + + + + + + + + ))} + +
时间类型路径来源设备 / 系统 / 浏览器用户IP状态
+ {new Date(e.created_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })} + + {kindBadge(e.kind)} + {e.bot ? ( + + {e.bot} + + ) : null} + + {e.path || "/"} + + {e.ref_host ? ( + <> + {e.ref_host} + {refKindLabel(e.ref_kind)} + + ) : ( + refKindLabel(e.ref_kind) + )} + + {deviceLabel(e.device)} · {e.os || "?"} · {e.browser || "?"} + + {e.username || 游客} + + {e.ip || "—"} + = 400 ? "var(--danger)" : "var(--ink-3)" }}> + {e.status > 0 ? e.status : "—"} +
+
+ + + ) : ( + + )} +
+
+ ); +} + +export default function DetailsPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/admin/analytics/layout.tsx b/frontend/app/admin/analytics/layout.tsx new file mode 100644 index 0000000..3a896e4 --- /dev/null +++ b/frontend/app/admin/analytics/layout.tsx @@ -0,0 +1,15 @@ +import { cookies } from "next/headers"; +import { authCookieHeader } from "@/lib/cookies"; +import { getMeCached } from "@/lib/serverData"; +import { isSuperOrOwner } from "@/lib/roles"; +import Forbidden from "../Forbidden"; +import AnalyticsShell from "./AnalyticsShell"; + +export default async function AnalyticsLayout({ children }: { children: React.ReactNode }) { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + if (!me.user || !isSuperOrOwner(me.user.role)) { + return ; + } + return {children}; +} diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx new file mode 100644 index 0000000..64c07ec --- /dev/null +++ b/frontend/app/admin/analytics/page.tsx @@ -0,0 +1,17 @@ +import { cookies } from "next/headers"; +import { authCookieHeader } from "@/lib/cookies"; +import { fetchStatsOverview, type VisitOverview } from "@/lib/api"; +import OverviewClient from "./OverviewClient"; + +export const dynamic = "force-dynamic"; + +export default async function AnalyticsOverviewPage() { + const cookie = authCookieHeader(await cookies()); + let initial: VisitOverview | null = null; + try { + initial = await fetchStatsOverview(cookie || undefined, "7d"); + } catch { + initial = null; + } + return ; +} diff --git a/frontend/app/admin/analytics/pages/page.tsx b/frontend/app/admin/analytics/pages/page.tsx new file mode 100644 index 0000000..c277eb3 --- /dev/null +++ b/frontend/app/admin/analytics/pages/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AdminPager } from "@/components/admin"; +import { AdminChartCard } from "@/components/admin/charts"; +import AdminSearchField from "@/components/admin/AdminSearchField"; +import { apiStatsPages, type VisitPages } from "@/lib/api"; +import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui"; + +const PAGE_SIZE = 20; + +export default function PagesPage() { + const [range, setRange] = useState<"7d" | "30d">("7d"); + const [q, setQ] = useState(""); + const [applied, setApplied] = useState(""); + const [page, setPage] = useState(1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const load = useCallback( + (r: "7d" | "30d", query: string, p: number) => { + setLoading(true); + apiStatsPages(r, query, p, PAGE_SIZE) + .then((d) => { + setData(d); + setError(""); + }) + .catch(() => setError("加载失败,请稍后重试")) + .finally(() => setLoading(false)); + }, + [] + ); + + useEffect(() => { + load(range, applied, page); + }, [load, range, applied, page]); + + const switchRange = (r: "7d" | "30d") => { + setRange(r); + setPage(1); + }; + + const submitSearch = () => { + setApplied(q.trim()); + setPage(1); + }; + + const clearSearch = () => { + setQ(""); + setApplied(""); + setPage(1); + }; + + if (!data) return ; + + const maxPv = data.items[0]?.pv ?? 1; + + return ( +
+
+

+ 共 {formatInt(data.total)} 个不同路径被访问(近 {data.range_days} 日) +

+ +
+ + {error ? : null} + + + +
+ } + > + {data.items.length ? ( + <> +
+ {data.items.map((p) => ( +
+ + {p.path || "/"} + + + + + + {formatInt(p.pv)} + + + UV {formatInt(p.uv)} + + + {p.share.toFixed(1)}% + +
+ ))} +
+ + + ) : ( + + )} + + + ); +} diff --git a/frontend/app/admin/analytics/realtime/page.tsx b/frontend/app/admin/analytics/realtime/page.tsx new file mode 100644 index 0000000..2b0b98f --- /dev/null +++ b/frontend/app/admin/analytics/realtime/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { Activity, Zap, Users, Clock } from "lucide-react"; +import { AdminStatCard } from "@/components/admin"; +import { AdminChartCard, SingleAreaChart } from "@/components/admin/charts"; +import { apiStatsRealtime, type VisitRealtime } from "@/lib/api"; +import { CardShell, StatsState, TableEmpty, deviceLabel, formatInt, kindLabel, usePolling } from "../ui"; + +export default function RealtimePage() { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + + const load = useCallback(() => { + apiStatsRealtime() + .then((d) => { + setData(d); + setError(""); + }) + .catch(() => setError("加载失败,请稍后重试")); + }, []); + usePolling(load, 15_000); + + if (!data) return ; + + // 兜底:旧后端可能返回 null(Go nil 切片) + const minutes = (data.minutes ?? []).map((m) => ({ + // SingleAreaChart 取 date 字段画 X 轴:只留 HH:MM + date: m.minute.length >= 16 ? m.minute.slice(11) : m.minute, + pv: m.pv, + })); + const topPages = data.top_pages ?? []; + const recent = data.recent ?? []; + + return ( +
+

每 15 秒自动刷新;切回前台标签页时立即刷新。

+ {error ? : null} + +
+ } /> + } + color="var(--ok)" + soft="var(--ok-soft, var(--panel-2))" + /> + } + color="var(--gold)" + soft="var(--gold-soft)" + /> + } + color="var(--ink-2)" + soft="var(--panel-2)" + /> +
+ + + {minutes.length ? ( + + ) : ( + + )} + + +
+ + {topPages.length ? ( +
    + {topPages.map((p) => ( +
  • + + {p.key || "/"} + + + {formatInt(p.count)} + +
  • + ))} +
+ ) : ( + + )} +
+ + + {recent.length ? ( +
    + {recent.map((e) => ( +
  • + + {new Date(e.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })} + + + {e.path || "/"} + + + {deviceLabel(e.device)} · {kindLabel(e.kind)} + +
  • + ))} +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/app/admin/analytics/security/page.tsx b/frontend/app/admin/analytics/security/page.tsx new file mode 100644 index 0000000..6ea6a4b --- /dev/null +++ b/frontend/app/admin/analytics/security/page.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Bot, ShieldAlert, KeyRound, Radar } from "lucide-react"; +import { AdminStatCard } from "@/components/admin"; +import { AdminChartCard, NamedBarChart, SingleAreaChart } from "@/components/admin/charts"; +import { apiStatsSecurity, type VisitSecurity } from "@/lib/api"; +import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui"; + +export default function SecurityPage() { + const [range, setRange] = useState<"7d" | "30d">("7d"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + setLoading(true); + apiStatsSecurity(range) + .then((d) => { + if (alive) { + setData(d); + setError(""); + } + }) + .catch(() => alive && setError("加载失败,请稍后重试")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [range]); + + if (!data) return ; + + const botTrend = data.bot_trend.map((d) => ({ date: d.date, pv: d.pv })); + + return ( +
+
+

爬虫与异常探测请求仅记录明细,不计入 PV/UV 统计。

+ +
+ + {error ? : null} + +
+ s + b.count, 0))} icon={} /> + s + b.count, 0))} + hint="状态码 ≥400 的请求" + icon={} + color="var(--gold)" + soft="var(--gold-soft)" + /> + } + color="var(--ink-2)" + soft="var(--panel-2)" + /> + } + color="var(--danger)" + soft="var(--danger-soft, var(--panel-2))" + /> +
+ +
+ + {data.bots.length ? ( + ({ label: b.key, count: b.count }))} /> + ) : ( + + )} + + + {botTrend.length ? ( + + ) : ( + + )} + +
+ +
+ + {data.probe_status.length ? ( + ({ label: s.key, count: s.count }))} /> + ) : ( + + )} + + + {data.probe_ips.length ? ( +
    + {data.probe_ips.map((ip) => ( +
  • + + {ip.key} + + + {formatInt(ip.count)} + + + 查明细 + +
  • + ))} +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/app/admin/analytics/settings/page.tsx b/frontend/app/admin/analytics/settings/page.tsx new file mode 100644 index 0000000..3c2cfe6 --- /dev/null +++ b/frontend/app/admin/analytics/settings/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Database, HardDrive, Trash2, Activity } from "lucide-react"; +import { AdminSettingRow, AdminStatCard, AdminSwitch } from "@/components/admin"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import { apiStatsPurge, apiStatsSaveSettings, apiStatsSettings, type VisitSettingsPayload } from "@/lib/api"; +import { StatsState, formatBytes, formatInt } from "../ui"; + +/** 保留天数输入边界(与后端 service 一致) */ +const RETENTION_MIN = 7; +const RETENTION_MAX = 365; + +export default function SettingsPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [enabled, setEnabled] = useState(true); + const [retention, setRetention] = useState(90); + const [saving, setSaving] = useState(false); + const [savedMsg, setSavedMsg] = useState(""); + // 立即清理:天数输入 + 确认弹窗 + 受理提示 + const [purgeDays, setPurgeDays] = useState(90); + const [purgeOpen, setPurgeOpen] = useState(false); + const [purging, setPurging] = useState(false); + const [purgeMsg, setPurgeMsg] = useState(""); + + const load = useCallback(() => { + apiStatsSettings() + .then((d) => { + setData(d); + setEnabled(d.enabled); + setRetention(d.retention_days); + setPurgeDays(d.retention_days); + setError(""); + }) + .catch(() => setError("加载失败,请稍后重试")) + .finally(() => setLoading(false)); + }, []); + useEffect(() => { + load(); + }, [load]); + + const save = (nextEnabled: boolean, nextRetention: number) => { + setSaving(true); + setSavedMsg(""); + apiStatsSaveSettings(nextEnabled, nextRetention) + .then((d) => { + setData(d); + setEnabled(d.enabled); + setRetention(d.retention_days); + setSavedMsg("已保存"); + setTimeout(() => setSavedMsg(""), 2000); + }) + .catch(() => setError("保存失败,请稍后重试")) + .finally(() => setSaving(false)); + }; + + const toggleEnabled = () => { + if (saving) return; + save(!enabled, retention); + }; + + const submitRetention = () => { + if (saving) return; + const v = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(Number(retention) || 90))); + setRetention(v); + save(enabled, v); + }; + + const doPurge = () => { + setPurging(true); + apiStatsPurge(purgeDays) + .then(() => { + setPurgeMsg(`清理任务已受理(删除 ${purgeDays} 天前的明细),稍后刷新本页查看容量变化。`); + setTimeout(load, 3000); + }) + .catch(() => setPurgeMsg("清理请求失败,请稍后重试。")) + .finally(() => { + setPurging(false); + setPurgeOpen(false); + }); + }; + + if (!data) return ; + + const clampPurge = (v: number) => + Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1))); + + return ( +
+ {error ? : null} + {purgeMsg ? ( +
+ {purgeMsg} +
+ ) : null} + +
+ } /> + } + color="var(--ok)" + soft="var(--ok-soft, var(--panel-2))" + /> + } color="var(--gold)" soft="var(--gold-soft)" /> + } + color="var(--ink-2)" + soft="var(--panel-2)" + /> +
+ + } + /> + + +
+ setRetention(Number(e.target.value))} + onBlur={submitRetention} + onKeyDown={(e) => e.key === "Enter" && submitRetention()} + className="admin-field-input" + style={{ width: 120 }} + aria-label="明细保留天数" + /> + + 天 + + {saving ? 保存中… : null} + {savedMsg ? ( + + {savedMsg} + + ) : null} +
+
+ + +
+ setPurgeDays(clampPurge(Number(e.target.value)))} + className="admin-field-input" + style={{ width: 120 }} + aria-label="清理多少天前的明细" + /> + + 天前的明细 + + +
+
+ + setPurgeOpen(false)} + /> +
+ ); +} diff --git a/frontend/app/admin/analytics/sources/page.tsx b/frontend/app/admin/analytics/sources/page.tsx new file mode 100644 index 0000000..50d1640 --- /dev/null +++ b/frontend/app/admin/analytics/sources/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts"; +import { apiStatsSources, type VisitSources } from "@/lib/api"; +import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui"; + +/** 来源按日表:固定展示四类主要来源列 */ +const TREND_KINDS = ["direct", "search", "social", "external"] as const; + +export default function SourcesPage() { + const [range, setRange] = useState<"7d" | "30d">("7d"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + setLoading(true); + apiStatsSources(range) + .then((d) => { + if (alive) { + setData(d); + setError(""); + } + }) + .catch(() => alive && setError("加载失败,请稍后重试")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [range]); + + if (!data) return ; + + const kinds = data.kinds.map((k) => ({ label: refKindLabel(k.key), count: k.count })); + + return ( +
+
+

按浏览来源(Referer)自动分类;爬虫与探测请求不计入。

+ +
+ + {error ? : null} + + + {kinds.length ? : } + + +
+ + {data.search_hosts.length ? ( + ({ label: h.key, count: h.count }))} /> + ) : ( + + )} + + + {data.external.length ? ( + ({ label: h.key, count: h.count }))} /> + ) : ( + + )} + +
+ + + {data.trend.length ? ( +
+ + + + + {TREND_KINDS.map((k) => ( + + ))} + + + + {data.trend.map((row) => { + const byKey = new Map(row.items.map((i) => [i.key, i.count])); + return ( + + + {TREND_KINDS.map((k) => ( + + ))} + + ); + })} + +
日期 + {refKindLabel(k)} +
+ {row.date.slice(0, 10)} + + {(byKey.get(k) ?? 0) === 0 ? ( + — + ) : ( + (byKey.get(k) ?? 0).toLocaleString("zh-CN") + )} +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/app/admin/analytics/ui.tsx b/frontend/app/admin/analytics/ui.tsx new file mode 100644 index 0000000..195786e --- /dev/null +++ b/frontend/app/admin/analytics/ui.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { onDocumentVisible } from "@/lib/visibilityReconcile"; + +/** 事件类型中文标签 */ +export function kindLabel(kind: string): string { + switch (kind) { + case "pageview": + return "浏览"; + case "bot": + return "爬虫"; + case "probe": + return "探测"; + default: + return kind || "-"; + } +} + +/** 来源类别中文标签 */ +export function refKindLabel(kind: string): string { + switch (kind) { + case "direct": + return "直接访问"; + case "internal": + return "站内"; + case "search": + return "搜索引擎"; + case "social": + return "社交"; + case "external": + return "外部链接"; + default: + return kind || "-"; + } +} + +export function deviceLabel(d: string): string { + switch (d) { + case "computer": + return "电脑"; + case "mobile": + return "手机"; + case "tablet": + return "平板"; + default: + return d || "未知"; + } +} + +export function formatBytes(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +export function formatInt(n: number): string { + return new Intl.NumberFormat("zh-CN").format(n); +} + +/** 简单加载/错误占位(居中卡片内文本) */ +export function StatsState({ loading, error }: { loading?: boolean; error?: string }) { + if (loading) { + return ( +
+ 正在加载… +
+ ); + } + if (error) { + return ( +
+ {error} +
+ ); + } + return null; +} + +export function TableEmpty({ text = "暂无数据" }: { text?: string }) { + return ( +

+ {text} +

+ ); +} + +/** 15 秒轮询 + 回前台即拉;返回手动 reload */ +export function usePolling(load: () => void, intervalMs = 15_000) { + const [, setTick] = useState(0); + useEffect(() => { + load(); + const timer = setInterval(load, intervalMs); + const off = onDocumentVisible(load); + return () => { + clearInterval(timer); + off(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intervalMs]); + return () => setTick((t) => t + 1); +} + +/** 7/30 日区间切换段控件(各 Tab 复用) */ +export function RangeSegmented({ + value, + onChange, +}: { + value: "7d" | "30d"; + onChange: (v: "7d" | "30d") => void; +}) { + return ( +
+ {( + [ + { key: "7d", label: "近 7 日" }, + { key: "30d", label: "近 30 日" }, + ] as const + ).map((o) => ( + + ))} +
+ ); +} + +export function CardShell({ + title, + hint, + actions, + children, +}: { + title: string; + hint?: string; + actions?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+

+ {title} +

+ {hint ?

{hint}

: null} +
+ {actions} +
+ {children} +
+ ); +} diff --git a/frontend/app/admin/analytics/visitors/page.tsx b/frontend/app/admin/analytics/visitors/page.tsx new file mode 100644 index 0000000..2e04fc6 --- /dev/null +++ b/frontend/app/admin/analytics/visitors/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Users, UserPlus, Repeat, LogIn } from "lucide-react"; +import { AdminStatCard } from "@/components/admin"; +import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts"; +import { apiStatsVisitors, type VisitVisitors } from "@/lib/api"; +import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui"; + +export default function VisitorsPage() { + const [range, setRange] = useState<"7d" | "30d">("7d"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + setLoading(true); + apiStatsVisitors(range) + .then((d) => { + if (alive) { + setData(d); + setError(""); + } + }) + .catch(() => alive && setError("加载失败,请稍后重试")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [range]); + + if (!data) return ; + + const devices = data.devices.map((d) => ({ label: d.label || "未知", count: d.count })); + const oses = data.oses.map((d) => ({ label: d.label || "未知", count: d.count })); + const browsers = data.browsers.map((d) => ({ label: d.label || "未知", count: d.count })); + const hourly = data.hourly.map((h) => ({ label: `${h.key}时`, count: h.count })); + const totalPv = data.logged_in_pv + data.guest_pv; + const totalVisitors = data.new_visitors + data.returning; + + return ( +
+
+

基于匿名访客标识(仅存哈希)与登录用户统计。

+ +
+ + {error ? : null} + +
+ } /> + } + color="var(--ok)" + soft="var(--ok-soft, var(--panel-2))" + /> + 0 ? `${Math.round((data.new_visitors / totalVisitors) * 100)}%` : undefined} + hint="统计期内首次出现" + icon={} + color="var(--gold)" + soft="var(--gold-soft)" + /> + 0 ? `${Math.round((data.returning / totalVisitors) * 100)}%` : undefined} + icon={} + color="var(--ink-2)" + soft="var(--panel-2)" + /> +
+ + {totalPv > 0 ? ( + + + + ) : null} + +
+ + {devices.length ? : } + + + {oses.length ? : } + + + {browsers.length ? : } + +
+ + + {hourly.length ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/frontend/app/admin/sidebar/SidebarAdmin.tsx b/frontend/app/admin/sidebar/SidebarAdmin.tsx new file mode 100644 index 0000000..cd4e000 --- /dev/null +++ b/frontend/app/admin/sidebar/SidebarAdmin.tsx @@ -0,0 +1,824 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + BarChart3, + Blocks, + ChevronDown, + CircleDollarSign, + Code2, + FileText, + Flame, + Handshake, + Home, + Info, + Layers, + LayoutGrid, + Link2, + List, + Megaphone, + PanelLeft, + Pencil, + PenLine, + Plus, + RotateCcw, + ShieldCheck, + Trash2, + User, + UserPlus, + Users, + type LucideIcon, +} from "lucide-react"; +import { + apiAdminCreateSidebarWidget, + apiAdminDeleteSidebarWidget, + apiAdminGetSidebar, + apiAdminSaveSidebarConfig, + apiAdminUpdateSidebarWidget, + type SidebarBlockRef, + type SidebarConfig, + type SidebarScope, + type SidebarWidgetRecord, +} from "@/lib/api"; +import { + SIDEBAR_BLOCK_ICON, + SIDEBAR_MAX_BLOCKS_PER_SIDE, + SIDEBAR_SCOPE_LABELS, + SIDEBAR_WIDGET_META, + defaultSidebarConfig, + sidebarBlockLabel, +} from "@/lib/sidebarMeta"; +import { + AdminEmpty, + AdminPageHeader, + AdminSettingsPage, + AdminSettingsWork, + AdminSwitch, +} from "@/components/admin"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import Modal from "@/components/Modal"; +import useUnsavedGuard from "@/hooks/useUnsavedGuard"; +import { toast } from "@/lib/toast"; +import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode"; + +type Side = "left" | "right"; +type SectionId = SidebarScope | "widgets"; + +const refKey = (r: SidebarBlockRef) => (r.widget ? `w:${r.widget}` : `k:${r.key}`); + +// lucide icon 映射(SIDEBAR_BLOCK_ICON 是字符串 key,这里解析成组件) +const ICON_MAP: Record = { + "layout-grid": LayoutGrid, + "bar-chart-3": BarChart3, + megaphone: Megaphone, + "circle-dollar-sign": CircleDollarSign, + handshake: Handshake, + flame: Flame, + "user-plus": UserPlus, + users: Users, + link2: Link2, + info: Info, + "shield-check": ShieldCheck, + "file-text": FileText, + layers: Layers, + user: User, + "pen-line": PenLine, + list: List, +}; +function keyIcon(key: string): LucideIcon | undefined { + return ICON_MAP[SIDEBAR_BLOCK_ICON[key] ?? ""]; +} + +const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[] }[] = [ + { scope: "home", icon: Home, desc: "首页全站态(含板块页左栏):左右两栏均可增删排序", sides: ["left", "right"] }, + { scope: "board", icon: Layers, desc: "板块页右栏;板块页左栏跟随「首页侧栏」配置", sides: ["right"] }, + { scope: "post", icon: FileText, desc: "帖子详情页:左右两栏(窄屏不渲染侧栏)", sides: ["left", "right"] }, +]; + +const SIDE_LABEL: Record = { left: "左栏", right: "右栏" }; +const WIDGET_CODE_MAX_BYTES = 64 * 1024; + +export default function SidebarAdmin({ + initialConfig, + initialWidgets, + initError, +}: { + initialConfig: SidebarConfig | null; + initialWidgets: SidebarWidgetRecord[]; + initError: string; +}) { + const [config, setConfig] = useState(() => initialConfig || defaultSidebarConfig()); + const [widgets, setWidgets] = useState(initialWidgets); + const [busy, setBusy] = useState(false); + const [activeSection, setActiveSection] = useState("home"); + const savedJsonRef = useRef(JSON.stringify(initialConfig || defaultSidebarConfig())); + const dirty = JSON.stringify(config) !== savedJsonRef.current; + const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !busy); + + // 工具编辑弹层 + const [editorOpen, setEditorOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [draftName, setDraftName] = useState(""); + const [mixed, setMixed] = useState(""); + const [deleteId, setDeleteId] = useState(null); + + const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]); + const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES; + + const reloadAll = async () => { + const res = await apiAdminGetSidebar(); + setConfig(res.config); + setWidgets(res.widgets); + savedJsonRef.current = JSON.stringify(res.config); + }; + + /* ---------- 布局配置 ---------- */ + + const setSide = (scope: SidebarScope, side: Side, next: SidebarBlockRef[]) => { + setConfig((c) => { + const cur = c.scopes[scope] ?? {}; + return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [side]: next } } }; + }); + }; + + const moveAcross = (scope: SidebarScope, from: Side, index: number) => { + const to: Side = from === "left" ? "right" : "left"; + setConfig((c) => { + const cur = c.scopes[scope] ?? {}; + const fromList = [...(cur[from] ?? [])]; + const [item] = fromList.splice(index, 1); + const toList = cur[to] ?? []; + if (!item || toList.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE) return c; + return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [from]: fromList, [to]: [...toList, item] } } }; + }); + }; + + const resetScope = (scope: SidebarScope) => { + const def = defaultSidebarConfig().scopes[scope] ?? {}; + setConfig((c) => ({ + ...c, + scopes: { ...c.scopes, [scope]: { left: def.left ?? [], right: def.right ?? [] } }, + })); + toast("已重置为默认布局,记得保存", "ok"); + }; + + const saveConfig = async () => { + setBusy(true); + try { + const res = await apiAdminSaveSidebarConfig(config); + setConfig(res.config); + savedJsonRef.current = JSON.stringify(res.config); + toast("侧栏布局已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败", "error"); + } finally { + setBusy(false); + } + }; + + /* ---------- 自定义工具 ---------- */ + + const openCreate = () => { + setEditingId(null); + setDraftName(""); + setMixed(""); + setEditorOpen(true); + }; + + const openEdit = (w: SidebarWidgetRecord) => { + setEditingId(w.id); + setDraftName(w.name); + setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js })); + setEditorOpen(true); + }; + + const submitWidget = async () => { + if (!draftOk) return; + setBusy(true); + try { + const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) }; + if (editingId == null) { + await apiAdminCreateSidebarWidget(code); + toast("工具已创建", "ok"); + } else { + const w = widgets.find((x) => x.id === editingId); + await apiAdminUpdateSidebarWidget(editingId, { ...code, enabled: w?.enabled ?? true }); + toast("工具已保存", "ok"); + } + setEditorOpen(false); + await reloadAll(); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败", "error"); + } finally { + setBusy(false); + } + }; + + const toggleWidget = async (w: SidebarWidgetRecord) => { + setBusy(true); + try { + await apiAdminUpdateSidebarWidget(w.id, { name: w.name, html: w.html, css: w.css, js: w.js, enabled: !w.enabled }); + toast(w.enabled ? "已停用" : "已启用", "ok"); + await reloadAll(); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败", "error"); + } finally { + setBusy(false); + } + }; + + const deleteWidget = async () => { + if (deleteId == null) return; + setBusy(true); + try { + await apiAdminDeleteSidebarWidget(deleteId); + toast("工具已删除,侧栏引用已同步清理", "ok"); + setDeleteId(null); + await reloadAll(); + } catch (e) { + toast(e instanceof Error ? e.message : "删除失败", "error"); + } finally { + setBusy(false); + } + }; + + /* ---------- 分区滚动导航 ---------- */ + + const sectionRefs = useRef>({ home: null, board: null, post: null, widgets: null }); + useEffect(() => { + const els = Object.values(sectionRefs.current).filter((el): el is HTMLElement => !!el); + if (els.length === 0) return; + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio); + if (visible[0]) setActiveSection((visible[0].target as HTMLElement).dataset.section as SectionId); + }, + { rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] } + ); + els.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, []); + const scrollTo = (id: SectionId) => sectionRefs.current[id]?.scrollIntoView({ behavior: "smooth", block: "start" }); + + if (initError && !initialConfig) return

{initError}

; + + // 各范围块数(含左右两侧合计,用于导航徽章) + const scopeCount = (s: SidebarScope) => { + const sc = config.scopes[s] ?? {}; + return (sc.left ?? []).length + (sc.right ?? []).length; + }; + const builtinCount = widgets.filter((w) => w.enabled).length; + + const navItems: { id: SectionId; label: string; icon: LucideIcon; count: number }[] = [ + ...SIDES.map((s) => ({ id: s.scope as SectionId, label: `${SIDEBAR_SCOPE_LABELS[s.scope]}侧栏`, icon: s.icon, count: scopeCount(s.scope) })), + { id: "widgets", label: "自定义工具", icon: Code2, count: builtinCount }, + ]; + + const leftNav = ( + + ); + + return ( + + + + +
+ {SIDES.map(({ scope, icon, desc, sides }) => { + const sc = config.scopes[scope] ?? {}; + const left = sc.left ?? []; + const right = sc.right ?? []; + const usedKeys = new Set([...left, ...right].map(refKey)); + const Icon = icon; + return ( +
{ sectionRefs.current[scope] = el; }} + className="panel" + > + } + title={`${SIDEBAR_SCOPE_LABELS[scope]}侧栏`} + desc={desc} + extra={ + + } + /> +
+ {sides.map((side) => ( + = SIDEBAR_MAX_BLOCKS_PER_SIDE} + widgets={widgets} + onChange={(next) => setSide(scope, side, next)} + onSendAcross={(i) => moveAcross(scope, side, i)} + /> + ))} +
+
+ ); + })} + + {/* 自定义工具 */} +
{ sectionRefs.current.widgets = el; }} + className="panel overflow-hidden" + > + } + title="自定义工具" + desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)" + extra={} + /> +
+ {widgets.length === 0 ? ( + } title="还没有自定义工具" description="创建后可在上方各页面的侧栏中添加使用" /> + ) : ( +
    + {widgets.map((w) => ( +
  • + + + +
    +

    + {w.name} + {!w.enabled && ( + 已停用 + )} +

    +

    + ID #{w.id} · HTML {w.html.length} / CSS {w.css.length} / JS {w.js.length} 字符 +

    +
    + + + +
  • + ))} +
+ )} +
+
+
+
+ + {/* 吸底保存栏 */} + {dirty && ( +
+

+ + 布局有未保存的修改 +

+ +
+ )} + + {/* 工具编辑弹层 */} + { if (!busy) setEditorOpen(false); }} + busy={busy} + maxWidthClass="max-w-2xl" + footer={ + <> + + + + } + > +
+ +