feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,8 @@ type Handlers struct {
|
||||
AdminUser *service.AdminUserService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Telemetry *service.TelemetryService
|
||||
Analytics *service.AnalyticsService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
@@ -34,6 +34,7 @@ type updateSettingsRequest struct {
|
||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome *bool `json:"anim_chrome"`
|
||||
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||
}
|
||||
|
||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
@@ -51,6 +52,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"anim_code_fold": saved.AnimCodeFold,
|
||||
"anim_smooth_scroll": saved.AnimSmoothScroll,
|
||||
"anim_chrome": saved.AnimChrome,
|
||||
"post_link_new_tab": saved.PostLinkNewTab,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
req.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil &&
|
||||
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil &&
|
||||
req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil &&
|
||||
req.AnimChrome == nil {
|
||||
req.AnimChrome == nil && req.PostLinkNewTab == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
||||
return
|
||||
}
|
||||
@@ -168,6 +170,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.PostLinkNewTab != nil {
|
||||
if err := h.Setting.SetPostLinkNewTab(*req.PostLinkNewTab); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
|
||||
56
backend/handler/telemetry.go
Normal file
56
backend/handler/telemetry.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const visitorCookie = "j13_vid"
|
||||
|
||||
func newVisitorID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
// 伪 UUID 形态,满足 ValidVisitorID
|
||||
return fmt.Sprintf("%s-%s-%s-%s-%s",
|
||||
hex.EncodeToString(b[0:4]),
|
||||
hex.EncodeToString(b[4:6]),
|
||||
hex.EncodeToString(b[6:8]),
|
||||
hex.EncodeToString(b[8:10]),
|
||||
hex.EncodeToString(b[10:16]),
|
||||
)
|
||||
}
|
||||
|
||||
// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF)
|
||||
func (h *Handlers) TelemetryPageView(c *gin.Context) {
|
||||
vid, _ := c.Cookie(visitorCookie)
|
||||
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)
|
||||
}
|
||||
|
||||
loggedIn := middleware.CurrentUser(c) != nil
|
||||
if err := h.Telemetry.RecordPageView(vid, loggedIn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/admin/analytics/overview?range=7d|30d
|
||||
func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) {
|
||||
rangeQ := c.DefaultQuery("range", "7d")
|
||||
data, err := h.Analytics.Overview(rangeQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func InitDB(dsn string) error {
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&PostPollVote{}, &PostLotteryEntry{},
|
||||
|
||||
@@ -400,6 +400,21 @@ type SiteSetting struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
||||
type SiteDailyStats struct {
|
||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||
PV int64 `gorm:"not null;default:0" json:"pv"`
|
||||
UV int64 `gorm:"not null;default:0" json:"uv"`
|
||||
LoggedInPV int64 `gorm:"not null;default:0" json:"logged_in_pv"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SiteDailyVisitor 当日访客去重(vid 哈希);插入成功才给 UV +1
|
||||
type SiteDailyVisitor struct {
|
||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||
VidHash string `gorm:"size:64;primaryKey" json:"-"`
|
||||
}
|
||||
|
||||
// 附件类型
|
||||
const (
|
||||
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
|
||||
|
||||
@@ -55,6 +55,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||
telemetrySvc := service.NewTelemetryService(model.DB)
|
||||
analyticsSvc := service.NewAnalyticsService(model.DB)
|
||||
if err := uploadSvc.EnsureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -82,6 +84,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
AdminUser: adminUserSvc,
|
||||
Moderation: moderationSvc,
|
||||
Chat: chatSvc,
|
||||
Telemetry: telemetrySvc,
|
||||
Analytics: analyticsSvc,
|
||||
}
|
||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||
notifSvc.OnNotifyNew = func(userID uint) {
|
||||
@@ -122,6 +126,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/announcements", h.AnnouncementsList)
|
||||
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
|
||||
pubAPI.GET("/settings", h.PublicSettings)
|
||||
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
|
||||
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
||||
@@ -223,6 +228,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
// 积分与类型帖概览(管理员及以上)
|
||||
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
|
||||
|
||||
// 站点分析概览(任意 staff)
|
||||
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
|
||||
|
||||
// 群聊监管队列(任意 staff;撤回权限仍按 oversee 规则)
|
||||
staffAPI.GET("/chat/messages", h.AdminChatMessages)
|
||||
staffAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage)
|
||||
|
||||
// 站点公告文章管理(管理员及以上)
|
||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||
|
||||
@@ -52,6 +52,7 @@ type AdminUserItem struct {
|
||||
Banned bool `json:"banned"`
|
||||
PostCount int64 `json:"post_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
Points int `json:"points"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
Online bool `json:"online"`
|
||||
@@ -188,6 +189,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
||||
Role: string(u.Role),
|
||||
BoardIDs: []uint{},
|
||||
Banned: u.Banned,
|
||||
Points: u.Points,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastSeenAt: u.LastSeenAt,
|
||||
}
|
||||
|
||||
224
backend/service/analytics.go
Normal file
224
backend/service/analytics.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AnalyticsService 管理端聚合分析
|
||||
type AnalyticsService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
|
||||
return &AnalyticsService{db: db}
|
||||
}
|
||||
|
||||
// DayCount 按日计数点
|
||||
type DayCount struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// VisitDay 访问日序列
|
||||
type VisitDay struct {
|
||||
Date string `json:"date"`
|
||||
PV int64 `json:"pv"`
|
||||
UV int64 `json:"uv"`
|
||||
}
|
||||
|
||||
// NamedCount 命名计数(板块/类型等)
|
||||
type NamedCount struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// TopViewPost 高浏览帖
|
||||
type TopViewPost struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ViewCount int `json:"view_count"`
|
||||
BoardID uint `json:"board_id"`
|
||||
}
|
||||
|
||||
// AnalyticsOverview 仪表盘聚合
|
||||
type AnalyticsOverview struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
TodayPV int64 `json:"today_pv"`
|
||||
TodayUV int64 `json:"today_uv"`
|
||||
Visits []VisitDay `json:"visits"`
|
||||
PostsSeries []DayCount `json:"posts_series"`
|
||||
CommentsSeries []DayCount `json:"comments_series"`
|
||||
RegistersSeries []DayCount `json:"registers_series"`
|
||||
Boards []NamedCount `json:"boards"`
|
||||
PostTypes []NamedCount `json:"post_types"`
|
||||
TopViewed []TopViewPost `json:"top_viewed"`
|
||||
}
|
||||
|
||||
func parseRangeDays(rangeQ string) int {
|
||||
switch rangeQ {
|
||||
case "30d", "30":
|
||||
return 30
|
||||
default:
|
||||
return 7
|
||||
}
|
||||
}
|
||||
|
||||
func dateKey(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount {
|
||||
out := make([]DayCount, 0)
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
k := dateKey(d)
|
||||
out = append(out, DayCount{Date: k, Count: got[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Overview 返回近 N 日访问与内容趋势
|
||||
func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
start := today.AddDate(0, 0, -(days - 1))
|
||||
end := today
|
||||
|
||||
out := &AnalyticsOverview{
|
||||
RangeDays: days,
|
||||
Visits: make([]VisitDay, 0, days),
|
||||
Boards: make([]NamedCount, 0),
|
||||
PostTypes: make([]NamedCount, 0),
|
||||
TopViewed: make([]TopViewPost, 0),
|
||||
}
|
||||
|
||||
var stats []model.SiteDailyStats
|
||||
if err := s.db.Where("date >= ? AND date <= ?", start, end).
|
||||
Order("date ASC").Find(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visitMap := map[string]model.SiteDailyStats{}
|
||||
for _, st := range stats {
|
||||
visitMap[dateKey(st.Date)] = st
|
||||
}
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
k := dateKey(d)
|
||||
st := visitMap[k]
|
||||
out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV})
|
||||
if k == dateKey(today) {
|
||||
out.TodayPV = st.PV
|
||||
out.TodayUV = st.UV
|
||||
}
|
||||
}
|
||||
|
||||
type dayRow struct {
|
||||
Day string
|
||||
Cnt int64
|
||||
}
|
||||
scanDayCounts := func(sql string, args ...interface{}) (map[string]int64, error) {
|
||||
var rows []dayRow
|
||||
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
m[r.Day] = r.Cnt
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
postMap, err := scanDayCounts(`
|
||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||
FROM posts
|
||||
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?
|
||||
GROUP BY day ORDER BY day`,
|
||||
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.PostsSeries = fillDaySeries(start, end, postMap)
|
||||
|
||||
commentMap, err := scanDayCounts(`
|
||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||
FROM comments
|
||||
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?
|
||||
GROUP BY day ORDER BY day`,
|
||||
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.CommentsSeries = fillDaySeries(start, end, commentMap)
|
||||
|
||||
regMap, err := scanDayCounts(`
|
||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?
|
||||
GROUP BY day ORDER BY day`,
|
||||
start, end.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.RegistersSeries = fillDaySeries(start, end, regMap)
|
||||
|
||||
type boardRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
PostCount int64
|
||||
}
|
||||
var boards []boardRow
|
||||
if err := s.db.Raw(`
|
||||
SELECT b.id, b.name, count(p.id) AS post_count
|
||||
FROM boards b
|
||||
LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ?
|
||||
WHERE b.deleted_at IS NULL
|
||||
GROUP BY b.id, b.name
|
||||
ORDER BY post_count DESC`, model.ContentStatusPublished).Scan(&boards).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range boards {
|
||||
out.Boards = append(out.Boards, NamedCount{
|
||||
Key: strconv.FormatUint(uint64(b.ID), 10),
|
||||
Label: b.Name,
|
||||
Count: b.PostCount,
|
||||
})
|
||||
}
|
||||
|
||||
type typeRow struct {
|
||||
PostType string
|
||||
Cnt int64
|
||||
}
|
||||
var types []typeRow
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Select("post_type, count(*) as cnt").
|
||||
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
|
||||
Group("post_type").Scan(&types).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range types {
|
||||
pt := model.NormalizePostType(t.PostType)
|
||||
out.PostTypes = append(out.PostTypes, NamedCount{
|
||||
Key: pt,
|
||||
Label: pt,
|
||||
Count: t.Cnt,
|
||||
})
|
||||
}
|
||||
|
||||
var tops []model.Post
|
||||
if err := s.db.Select("id, title, view_count, board_id").
|
||||
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
|
||||
Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range tops {
|
||||
out.TopViewed = append(out.TopViewed, TopViewPost{
|
||||
ID: p.ID, Title: p.Title, ViewCount: p.ViewCount, BoardID: p.BoardID,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -152,20 +152,36 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// DeltaDay 单日积分发放与消耗
|
||||
type DeltaDay struct {
|
||||
Date string `json:"date"`
|
||||
Granted int64 `json:"granted"`
|
||||
Spent int64 `json:"spent"`
|
||||
}
|
||||
|
||||
// PointsEconomyStats 全站积分与类型帖概览(管理端)
|
||||
type PointsEconomyStats struct {
|
||||
TotalBalance int64 `json:"total_balance"` // 用户余额合计
|
||||
LedgerCount int64 `json:"ledger_count"` // 流水条数
|
||||
CheckinToday int64 `json:"checkin_today"` // 今日签到人数
|
||||
OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖
|
||||
EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算)
|
||||
PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量
|
||||
RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note)
|
||||
TotalBalance int64 `json:"total_balance"`
|
||||
LedgerCount int64 `json:"ledger_count"`
|
||||
CheckinToday int64 `json:"checkin_today"`
|
||||
OpenBounties int64 `json:"open_bounties"`
|
||||
EscrowedPoints int64 `json:"escrowed_points"`
|
||||
PostsByType map[string]int64 `json:"posts_by_type"`
|
||||
RecentLedger []LedgerItem `json:"recent_ledger"`
|
||||
CheckinSeries []DayCount `json:"checkin_series"`
|
||||
LedgerByReason []NamedCount `json:"ledger_by_reason"`
|
||||
DeltaSeries []DeltaDay `json:"delta_series"`
|
||||
NetDelta7d int64 `json:"net_delta_7d"`
|
||||
}
|
||||
|
||||
// AdminEconomyStats 管理端经济看板
|
||||
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||||
st := &PointsEconomyStats{PostsByType: map[string]int64{}}
|
||||
st := &PointsEconomyStats{
|
||||
PostsByType: map[string]int64{},
|
||||
CheckinSeries: make([]DayCount, 0),
|
||||
LedgerByReason: make([]NamedCount, 0),
|
||||
DeltaSeries: make([]DeltaDay, 0),
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
|
||||
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -173,14 +189,11 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||||
if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
// 用日期字符串更稳妥(与 checkin 一致用 date)
|
||||
if err := s.db.Model(&model.Checkin{}).
|
||||
Where("checkin_date::date = CURRENT_DATE").
|
||||
Count(&st.CheckinToday).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = today
|
||||
|
||||
type row struct {
|
||||
PostType string
|
||||
@@ -198,7 +211,6 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||||
st.PostsByType[pt] += r.Cnt
|
||||
}
|
||||
|
||||
// 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
|
||||
var bountyPosts []model.Post
|
||||
if err := s.db.Select("id, type_meta").
|
||||
Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
||||
@@ -227,5 +239,77 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||||
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
start14 := day0.AddDate(0, 0, -13)
|
||||
start7 := day0.AddDate(0, 0, -6)
|
||||
|
||||
type dayCnt struct {
|
||||
Day string
|
||||
Cnt int64
|
||||
}
|
||||
var checkinRows []dayCnt
|
||||
if err := s.db.Raw(`
|
||||
SELECT to_char(checkin_date, 'YYYY-MM-DD') AS day, count(*) AS cnt
|
||||
FROM checkins
|
||||
WHERE checkin_date >= ? AND checkin_date <= ?
|
||||
GROUP BY day ORDER BY day`, start14, day0).Scan(&checkinRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checkinMap := map[string]int64{}
|
||||
for _, r := range checkinRows {
|
||||
checkinMap[r.Day] = r.Cnt
|
||||
}
|
||||
st.CheckinSeries = fillDaySeries(start14, day0, checkinMap)
|
||||
|
||||
type reasonRow struct {
|
||||
Reason string
|
||||
Cnt int64
|
||||
}
|
||||
var reasons []reasonRow
|
||||
if err := s.db.Model(&model.PointLedger{}).
|
||||
Select("reason, count(*) as cnt").
|
||||
Group("reason").
|
||||
Order("cnt DESC").
|
||||
Limit(12).Scan(&reasons).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range reasons {
|
||||
st.LedgerByReason = append(st.LedgerByReason, NamedCount{
|
||||
Key: r.Reason, Label: r.Reason, Count: r.Cnt,
|
||||
})
|
||||
}
|
||||
|
||||
type deltaRow struct {
|
||||
Day string
|
||||
Granted int64
|
||||
Spent int64
|
||||
}
|
||||
var deltas []deltaRow
|
||||
if err := s.db.Raw(`
|
||||
SELECT to_char(created_at, 'YYYY-MM-DD') AS day,
|
||||
COALESCE(SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END), 0) AS granted,
|
||||
COALESCE(SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END), 0) AS spent
|
||||
FROM point_ledgers
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
GROUP BY day ORDER BY day`,
|
||||
start14, day0.AddDate(0, 0, 1)).Scan(&deltas).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deltaMap := map[string]DeltaDay{}
|
||||
for _, r := range deltas {
|
||||
deltaMap[r.Day] = DeltaDay{Date: r.Day, Granted: r.Granted, Spent: r.Spent}
|
||||
}
|
||||
for d := start14; !d.After(day0); d = d.AddDate(0, 0, 1) {
|
||||
k := dateKey(d)
|
||||
dd := deltaMap[k]
|
||||
dd.Date = k
|
||||
st.DeltaSeries = append(st.DeltaSeries, dd)
|
||||
if !d.Before(start7) {
|
||||
st.NetDelta7d += dd.Granted - dd.Spent
|
||||
}
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const (
|
||||
SettingKeyAnimSmoothScroll = "anim_smooth_scroll"
|
||||
// SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启
|
||||
SettingKeyAnimChrome = "anim_chrome"
|
||||
// SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制)
|
||||
SettingKeyPostLinkNewTab = "post_link_new_tab"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,6 +74,7 @@ type PublicSiteSettings struct {
|
||||
AnimCodeFold bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome bool `json:"anim_chrome"`
|
||||
PostLinkNewTab bool `json:"post_link_new_tab"`
|
||||
}
|
||||
|
||||
// SettingService 站点级键值设置
|
||||
@@ -139,6 +142,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
AnimCodeFold: true,
|
||||
AnimSmoothScroll: true,
|
||||
AnimChrome: true,
|
||||
PostLinkNewTab: true,
|
||||
}
|
||||
accent, err := s.AccentColor()
|
||||
if err != nil {
|
||||
@@ -224,6 +228,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
return out, err
|
||||
}
|
||||
out.AnimChrome = animChrome
|
||||
|
||||
linkNewTab, err := s.PostLinkNewTab()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.PostLinkNewTab = linkNewTab
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -433,3 +443,16 @@ func (s *SettingService) AnimChrome() (bool, error) {
|
||||
func (s *SettingService) SetAnimChrome(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyAnimChrome, on)
|
||||
}
|
||||
|
||||
// PostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开。缺行视为开启。
|
||||
func (s *SettingService) PostLinkNewTab() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyPostLinkNewTab)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
return parseBoolDefaultTrue(v, found), nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetPostLinkNewTab(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on)
|
||||
}
|
||||
|
||||
88
backend/service/telemetry.go
Normal file
88
backend/service/telemetry.go
Normal file
@@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user