- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件 - 新增关注功能:follow 服务与 handler、FollowButton 组件 - 用户主页适配关注/粉丝与收藏展示 - flash 消息工具、广告与侧边栏组件微调
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package service
|
||
|
||
import (
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数
|
||
type OverviewService struct {
|
||
db *gorm.DB
|
||
ads *AdService
|
||
}
|
||
|
||
func NewOverviewService(db *gorm.DB) *OverviewService {
|
||
return &OverviewService{db: db}
|
||
}
|
||
|
||
func (s *OverviewService) WithAds(ads *AdService) *OverviewService {
|
||
s.ads = ads
|
||
return s
|
||
}
|
||
|
||
// 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"`
|
||
Pinned bool `json:"pinned"`
|
||
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"`
|
||
AnnouncementsTotal int64 `json:"announcements_total"`
|
||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||
NewUsers []NewUserItem `json:"new_users"`
|
||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||
Ads []PublicAdItem `json:"ads"`
|
||
AdsPanelTitle string `json:"ads_panel_title"`
|
||
AdsEnabled bool `json:"ads_enabled"`
|
||
Sponsors []PublicSponsorItem `json:"sponsors"`
|
||
SponsorsPanelTitle string `json:"sponsors_panel_title"`
|
||
SponsorsEnabled bool `json:"sponsors_enabled"`
|
||
}
|
||
|
||
// PublicAdItem 侧栏广告条目
|
||
type PublicAdItem struct {
|
||
ID uint `json:"id"`
|
||
Kind string `json:"kind"`
|
||
LinkURL string `json:"link_url"`
|
||
ImageURL string `json:"image_url,omitempty"`
|
||
Title string `json:"title,omitempty"`
|
||
TextColor string `json:"text_color,omitempty"`
|
||
BgColor string `json:"bg_color,omitempty"`
|
||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||
}
|
||
|
||
// PublicSponsorItem 侧栏赞助商
|
||
type PublicSponsorItem struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
LogoURL string `json:"logo_url"`
|
||
LinkURL string `json:"link_url,omitempty"`
|
||
HoverText string `json:"hover_text,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{},
|
||
SidebarPages: []SidebarPageItem{},
|
||
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").Preload("User.Badges.Badge").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 条)+ 总数
|
||
if err := s.db.Model(&model.Announcement{}).Where("published = ?", true).
|
||
Count(&data.AnnouncementsTotal).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
var anns []model.Announcement
|
||
if err := s.db.Where("published = ?", true).
|
||
Order("pinned DESC, 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,
|
||
Pinned: a.Pinned,
|
||
CreatedAt: a.CreatedAt,
|
||
})
|
||
}
|
||
|
||
// 侧栏单页入口(已发布且勾选侧栏)
|
||
var pages []model.SitePage
|
||
if err := s.db.Where("published = ? AND show_in_sidebar = ?", true, true).
|
||
Order("sort_order ASC, id ASC").
|
||
Select("id", "slug", "title", "excerpt").
|
||
Find(&pages).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
for _, p := range pages {
|
||
data.SidebarPages = append(data.SidebarPages, SidebarPageItem{
|
||
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
|
||
})
|
||
}
|
||
|
||
// 最新注册成员
|
||
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,
|
||
})
|
||
}
|
||
|
||
data.Ads = []PublicAdItem{}
|
||
data.AdsPanelTitle = AdDefaultTitle
|
||
data.Sponsors = []PublicSponsorItem{}
|
||
data.SponsorsPanelTitle = SponsorDefaultTitle
|
||
if s.ads != nil {
|
||
list, cfg, err := s.ads.ActivePublic()
|
||
if err == nil {
|
||
data.AdsEnabled = cfg.Enabled
|
||
data.AdsPanelTitle = cfg.PanelTitle
|
||
for _, a := range list {
|
||
data.Ads = append(data.Ads, PublicAdItem{
|
||
ID: a.ID, Kind: a.Kind, LinkURL: a.LinkURL,
|
||
ImageURL: a.ImageURL, Title: a.Title,
|
||
TextColor: a.TextColor, BgColor: a.BgColor,
|
||
EndsAt: a.EndsAt,
|
||
})
|
||
}
|
||
}
|
||
scfg, slist, err := s.ads.PublicSponsors()
|
||
if err == nil {
|
||
data.SponsorsEnabled = scfg.Enabled
|
||
data.SponsorsPanelTitle = scfg.PanelTitle
|
||
for _, it := range slist {
|
||
data.Sponsors = append(data.Sponsors, PublicSponsorItem{
|
||
ID: it.ID, Name: it.Name, LogoURL: it.LogoURL,
|
||
LinkURL: it.LinkURL, HoverText: it.HoverText,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|