feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -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,
}

View 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
}

View File

@@ -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
}

View File

@@ -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)
}

View 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
}