Files
jiang13-bbs/backend/service/overview.go
freefire 996a870015 feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化:
1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示
2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制
3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频
4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化
5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
2026-09-13 21:38:01 +08:00

291 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"strconv"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数
type OverviewService struct {
db *gorm.DB
}
func NewOverviewService(db *gorm.DB) *OverviewService {
return &OverviewService{db: db}
}
// OverviewStats 社区整体统计(社区脉搏卡)
type OverviewStats struct {
Posts int64 `json:"posts"`
Users int64 `json:"users"`
Comments int64 `json:"comments"`
TodayPosts int64 `json:"today_posts"`
TodayUsers int64 `json:"today_users"`
TodayComments int64 `json:"today_comments"`
Online int64 `json:"online"` // 近 5 分钟活跃用户
PeakOnline int64 `json:"peak_online"` // 历史在线峰值
}
// ActiveUser 本周活跃用户(发帖数 / 回复数分列)
type ActiveUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
}
// BoardCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
type BoardCount struct {
model.Board
PostCount int64 `json:"post_count"`
}
// AnnouncementItem 首页"站点公告"条目(标签/日期/标题)
type AnnouncementItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Tag string `json:"tag"`
TagColor string `json:"tag_color"`
CreatedAt time.Time `json:"created_at"`
}
// NewUserItem 最新注册成员
type NewUserItem struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
CreatedAt time.Time `json:"created_at"`
}
// OverviewData 首页聚合数据
type OverviewData struct {
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
}
const (
// onlineWindow 判定"在线"的最近活跃窗口
onlineWindow = 5 * time.Minute
// peakOnlineKey 历史在线峰值在 site_settings 中的键
peakOnlineKey = "peak_online"
)
// startOfWeek 返回本周一 00:00(本地时区)
func startOfWeek(t time.Time) time.Time {
wd := int(t.Weekday())
if wd == 0 {
wd = 7 // 周日归到本周末尾
}
d := t.AddDate(0, 0, -(wd - 1))
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, t.Location())
}
// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。
func (s *OverviewService) Get() (*OverviewData, error) {
data := &OverviewData{
Hot: []PostListItem{},
ActiveUsers: []ActiveUser{},
Boards: []BoardCount{},
Announcements: []AnnouncementItem{},
NewUsers: []NewUserItem{},
}
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
weekStart := startOfWeek(now)
onlineSince := now.Add(-onlineWindow)
// 统计
postQ := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
if err := postQ.Count(&data.Stats.Posts).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.User{}).Count(&data.Stats.Users).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&data.Stats.Comments).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.Post{}).
Where("status = ? AND created_at >= ?", model.ContentStatusPublished, dayStart).
Count(&data.Stats.TodayPosts).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.User{}).
Where("created_at >= ?", dayStart).
Count(&data.Stats.TodayUsers).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.Comment{}).
Where("status = ? AND created_at >= ?", model.ContentStatusPublished, dayStart).
Count(&data.Stats.TodayComments).Error; err != nil {
return nil, err
}
// 在线用户(近 5 分钟有请求)与历史峰值
if err := s.db.Model(&model.User{}).
Where("last_seen_at IS NOT NULL AND last_seen_at >= ? AND banned = ?", onlineSince, false).
Count(&data.Stats.Online).Error; err != nil {
return nil, err
}
data.Stats.PeakOnline = s.refreshPeakOnline(data.Stats.Online)
// 热门榜:非置顶,按 赞 + 评论×2
var hotPosts []model.Post
if err := s.db.Where("status = ? AND pinned = 0", model.ContentStatusPublished).
Order("(like_count + comment_count * 2) DESC, created_at DESC").
Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
return nil, err
}
data.Hot = toPostListItems(hotPosts)
// 本周活跃用户:发帖/评论分别计数,JOIN 用户过滤封禁/软删
type activityRow struct {
UserID uint
PostCount int64
CommentCount int64
}
var rows []activityRow
activitySQL := `
SELECT a.user_id AS user_id,
SUM(a.is_post) AS post_count,
SUM(1 - a.is_post) AS comment_count
FROM (
SELECT user_id, 1 AS is_post FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
UNION ALL
SELECT user_id, 0 AS is_post FROM comments WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
) AS a
JOIN users AS u ON u.id = a.user_id
WHERE u.banned = ? AND u.deleted_at IS NULL
GROUP BY a.user_id
ORDER BY COUNT(*) DESC, a.user_id ASC
LIMIT 5`
if err := s.db.Raw(activitySQL,
model.ContentStatusPublished, weekStart,
model.ContentStatusPublished, weekStart,
false).Scan(&rows).Error; err != nil {
return nil, err
}
if len(rows) > 0 {
ids := make([]uint, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.UserID)
}
var users []model.User
if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
userMap := make(map[uint]model.User, len(users))
for _, u := range users {
userMap[u.ID] = u
}
for _, r := range rows {
u, ok := userMap[r.UserID]
if !ok {
continue
}
data.ActiveUsers = append(data.ActiveUsers, ActiveUser{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
PostCount: r.PostCount,
CommentCount: r.CommentCount,
})
}
}
// 板块 + 各自帖子计数(一条 GROUP BY)
var boards []model.Board
if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil {
return nil, err
}
type countRow struct {
BoardID uint
Cnt int64
}
var counts []countRow
if err := s.db.Model(&model.Post{}).
Select("board_id, COUNT(*) AS cnt").
Where("status = ?", model.ContentStatusPublished).
Group("board_id").Scan(&counts).Error; err != nil {
return nil, err
}
countMap := make(map[uint]int64, len(counts))
for _, c := range counts {
countMap[c.BoardID] = c.Cnt
}
for _, b := range boards {
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
}
// 站点公告(已发布,最新 5 条)
var anns []model.Announcement
if err := s.db.Where("published = ?", true).
Order("created_at DESC, id DESC").Limit(5).Find(&anns).Error; err != nil {
return nil, err
}
for _, a := range anns {
data.Announcements = append(data.Announcements, AnnouncementItem{
ID: a.ID,
Title: a.Title,
Tag: a.Tag,
TagColor: a.TagColor,
CreatedAt: a.CreatedAt,
})
}
// 最新注册成员
var newUsers []model.User
if err := s.db.Select("id, username, nickname, avatar, created_at").
Where("banned = ?", false).
Order("created_at DESC").Limit(8).Find(&newUsers).Error; err != nil {
return nil, err
}
for _, u := range newUsers {
data.NewUsers = append(data.NewUsers, NewUserItem{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt,
})
}
return data, nil
}
// refreshPeakOnline 读取历史峰值,当前在线更高则落库刷新
func (s *OverviewService) refreshPeakOnline(online int64) int64 {
var setting model.SiteSetting
err := s.db.Where("key = ?", peakOnlineKey).First(&setting).Error
if err == gorm.ErrRecordNotFound {
setting = model.SiteSetting{Key: peakOnlineKey, Value: strconv.FormatInt(online, 10)}
if online > 0 {
s.db.Create(&setting)
}
return online
}
if err != nil {
return 0
}
peak, _ := strconv.ParseInt(setting.Value, 10, 64)
if online > peak {
s.db.Model(&model.SiteSetting{}).Where("key = ?", peakOnlineKey).
Update("value", strconv.FormatInt(online, 10))
return online
}
return peak
}