feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -158,6 +158,12 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
||||
if recentPosts == nil {
|
||||
recentPosts = []model.Post{}
|
||||
}
|
||||
traffic := service.DashboardTraffic{}
|
||||
if h.Monitor != nil {
|
||||
traffic = h.Monitor.DashboardTraffic()
|
||||
} else {
|
||||
traffic.Enabled = h.Settings.MonitorEnabled()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||
"comments": commentCount,
|
||||
@@ -166,6 +172,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
||||
"pending_reports": pendingReports,
|
||||
"pending_friend_links": pendingFriendLinks,
|
||||
"recent_posts": recentPosts,
|
||||
"traffic": traffic,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -574,7 +581,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
||||
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||
"storage": h.Settings.StorageConfigPublic(),
|
||||
"branding": h.Settings.SiteBranding(),
|
||||
"community": h.Settings.CommunityConfig(),
|
||||
"community": h.Settings.CommunityConfigForRequest(communityRequestOrigin(c)),
|
||||
"filter_words": filterContent,
|
||||
"filter_word_count": service.CountFilterWords(filterContent),
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ func (h *Handlers) APICommunityHeartbeat(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Community.ReceiveHeartbeat(req, c.ClientIP()); err != nil {
|
||||
if err := h.Community.ReceiveHeartbeat(req, c.ClientIP(), requestOrigin(c)); err != nil {
|
||||
if errors.Is(err, service.ErrCommunityHubDisabled) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"})
|
||||
return
|
||||
@@ -43,7 +43,7 @@ func (h *Handlers) APICommunityShowcase(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": []any{}})
|
||||
return
|
||||
}
|
||||
list, err := h.Community.ListShowcase()
|
||||
list, err := h.Community.ListShowcase(requestOrigin(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
|
||||
return
|
||||
@@ -57,7 +57,7 @@ func (h *Handlers) APIAdminCommunityInstances(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false})
|
||||
return
|
||||
}
|
||||
cfg := h.Settings.CommunityConfig()
|
||||
cfg := h.Settings.CommunityConfigForRequest(communityRequestOrigin(c))
|
||||
list, err := h.Community.ListInstances()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
|
||||
@@ -108,23 +108,64 @@ func (h *Handlers) APIAdminUpdateCommunitySettings(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := h.Settings.CommunityConfig()
|
||||
origin := communityRequestOrigin(c)
|
||||
cfg := h.Settings.CommunityConfigForRequest(origin)
|
||||
out := gin.H{
|
||||
"message": "社区设置已保存",
|
||||
"community": cfg,
|
||||
}
|
||||
if cfg.ReportEnabled && h.Community != nil {
|
||||
origin := communityRequestOrigin(c)
|
||||
if cfg.ReportEnabled && !cfg.HubEnabled && h.Community != nil {
|
||||
if err := h.Community.SendHeartbeatOnce(origin); err != nil {
|
||||
out["message"] = "社区设置已保存,但心跳未成功"
|
||||
out["heartbeat_error"] = err.Error()
|
||||
// 刷新 site_url(可能已由 Origin 持久化)
|
||||
out["community"] = h.Settings.CommunityConfig()
|
||||
out["community"] = h.Settings.CommunityConfigForRequest(origin)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// APIAdminUpdateShowcaseEntry 更新开源展柜入口展示位置(左栏 / 右栏 / 页脚)
|
||||
func (h *Handlers) APIAdminUpdateShowcaseEntry(c *gin.Context) {
|
||||
var req struct {
|
||||
NavShowShowcase *bool `json:"nav_show_showcase"`
|
||||
FooterShowShowcase *bool `json:"footer_show_showcase"`
|
||||
AsideShowShowcase *bool `json:"aside_show_showcase"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.NavShowShowcase == nil && req.FooterShowShowcase == nil && req.AsideShowShowcase == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.NavShowShowcase != nil {
|
||||
if err := h.Settings.SetNavShowShowcase(*req.NavShowShowcase); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.FooterShowShowcase != nil {
|
||||
if err := h.Settings.SetFooterShowShowcase(*req.FooterShowShowcase); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AsideShowShowcase != nil {
|
||||
if err := h.Settings.SetAsideShowcaseEnabled(*req.AsideShowShowcase); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "展柜入口已保存",
|
||||
"nav_show_showcase": h.Settings.NavShowShowcase(),
|
||||
"footer_show_showcase": h.Settings.FooterShowShowcase(),
|
||||
"aside_show_showcase": h.Settings.AsideShowShowcase(),
|
||||
})
|
||||
}
|
||||
|
||||
// communityRequestOrigin 优先用浏览器 Origin(Vite 代理时 Host 可能是后端端口)
|
||||
func communityRequestOrigin(c *gin.Context) string {
|
||||
if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" {
|
||||
|
||||
@@ -29,6 +29,7 @@ type Handlers struct {
|
||||
Report *service.ReportService
|
||||
Backup *service.BackupService
|
||||
Community *service.CommunityService
|
||||
Monitor *service.MonitorService
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
Settings *service.ForumSettingsService
|
||||
|
||||
108
handler/monitor.go
Normal file
108
handler/monitor.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIAdminMonitorOverview 今日概览
|
||||
func (h *Handlers) APIAdminMonitorOverview(c *gin.Context) {
|
||||
if h.Monitor == nil {
|
||||
c.JSON(http.StatusOK, service.MonitorOverview{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.Monitor.OverviewToday())
|
||||
}
|
||||
|
||||
// APIAdminMonitorGeo 地理分布
|
||||
func (h *Handlers) APIAdminMonitorGeo(c *gin.Context) {
|
||||
if h.Monitor == nil {
|
||||
c.JSON(http.StatusOK, service.MonitorGeoResult{
|
||||
Countries: []service.MonitorGeoItem{},
|
||||
Regions: []service.MonitorRegionItem{},
|
||||
Cities: []service.MonitorCityItem{},
|
||||
ASNs: []service.MonitorASNItem{},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.Monitor.GeoStats(c.Query("range")))
|
||||
}
|
||||
|
||||
// APIAdminMonitorStats 维度排行
|
||||
func (h *Handlers) APIAdminMonitorStats(c *gin.Context) {
|
||||
if h.Monitor == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"items": []service.MonitorStatItem{}})
|
||||
return
|
||||
}
|
||||
items := h.Monitor.DimStats(c.Query("dim"), c.Query("range"))
|
||||
c.JSON(http.StatusOK, gin.H{"dim": c.Query("dim"), "range": c.Query("range"), "items": items})
|
||||
}
|
||||
|
||||
// APIAdminMonitorLogs 请求日志
|
||||
func (h *Handlers) APIAdminMonitorLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
if h.Monitor == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"items": []service.MonitorLogItem{}, "total": 0, "page": page, "size": size})
|
||||
return
|
||||
}
|
||||
items, total := h.Monitor.ListLogs(page, size, c.Query("method"), c.Query("path"), c.Query("status"), c.Query("ip"))
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// APIAdminMonitorRealtime 实时
|
||||
func (h *Handlers) APIAdminMonitorRealtime(c *gin.Context) {
|
||||
if h.Monitor == nil {
|
||||
c.JSON(http.StatusOK, service.MonitorRealtime{HourlySeries: []service.MonitorRealtimePoint{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.Monitor.Realtime())
|
||||
}
|
||||
|
||||
// APIAdminGetMonitorSettings 读取监控设置
|
||||
func (h *Handlers) APIAdminGetMonitorSettings(c *gin.Context) {
|
||||
cfg := h.Settings.MonitorConfig()
|
||||
if h.Monitor != nil {
|
||||
h.Monitor.EnrichGeoMeta(&cfg)
|
||||
}
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// APIAdminUpdateMonitorSettings 更新监控设置
|
||||
func (h *Handlers) APIAdminUpdateMonitorSettings(c *gin.Context) {
|
||||
var req service.MonitorConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Settings.UpdateMonitorConfig(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := h.Settings.MonitorConfig()
|
||||
if h.Monitor != nil {
|
||||
h.Monitor.EnrichGeoMeta(&cfg)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "监控设置已保存", "monitor": cfg})
|
||||
}
|
||||
|
||||
// APIMonitorPageview 前台 SPA 路由 pageview 信标(公开,受 monitor_enabled 控制)
|
||||
func (h *Handlers) APIMonitorPageview(c *gin.Context) {
|
||||
if h.Monitor == nil || !h.Settings.MonitorEnabled() {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var req service.PageViewInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Monitor.RecordPageView(c.Request, c.Request.RemoteAddr, req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user