Files
jiang13-bbs/backend/service/overview.go
freefire 9bae6f2513 feat: 添加每日签到功能,新增帖子排序与社区面板优化
本次提交完成了多个核心功能迭代:
1.  新增用户每日签到系统,包含签到状态查询、连续天数统计、积分累计逻辑
2.  新增"新帖子"排序选项,替换原有的latest排序为新评论排序
3.  重构首页信息流布局,优化移动端与桌面端展示样式
4.  新增最新评论、最新注册用户模块,完善社区面板数据展示
5.  新增静态友情链接配置模块
6.  提取通用时间格式化工具函数,统一全站相对时间展示
7.  补充数据库签到模型与后端API接口,完善前后端交互链路
2026-09-13 06:03:40 +08:00

270 lines
7.7 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 (
"strings"
"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"`
}
// ActiveUser 近 14 天活跃用户
type ActiveUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ActivityCount int64 `json:"activity_count"`
}
// BoardCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
type BoardCount struct {
model.Board
PostCount int64 `json:"post_count"`
}
// LatestCommentItem 首页"最新评论"条目(纯文本摘要 + 所属帖子)
type LatestCommentItem struct {
ID uint `json:"id"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
User struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
} `json:"user"`
Post struct {
ID uint `json:"id"`
Title string `json:"title"`
} `json:"post"`
}
// 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"`
LatestComments []LatestCommentItem `json:"latest_comments"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
}
// truncateText 折叠空白并按 rune 截断(评论为纯文本,无 HTML 注入面)
func truncateText(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) > n {
return string(r[:n]) + "…"
}
return s
}
const activeUserWindow = 14 * 24 * time.Hour
// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。
func (s *OverviewService) Get() (*OverviewData, error) {
data := &OverviewData{
Hot: []PostListItem{},
ActiveUsers: []ActiveUser{},
Boards: []BoardCount{},
LatestComments: []LatestCommentItem{},
NewUsers: []NewUserItem{},
}
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
since := now.Add(-activeUserWindow)
// 统计
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
}
// 热门榜:非置顶,按 赞 + 评论×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)
// 活跃用户:近 14 天发帖+评论合并计数,JOIN 用户过滤封禁/软删
type activityRow struct {
UserID uint
ActivityCount int64
}
var rows []activityRow
activitySQL := `
SELECT a.user_id AS user_id, COUNT(*) AS activity_count
FROM (
SELECT user_id FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
UNION ALL
SELECT user_id 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 activity_count DESC, a.user_id ASC
LIMIT 5`
if err := s.db.Raw(activitySQL,
model.ContentStatusPublished, since,
model.ContentStatusPublished, since,
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,
ActivityCount: r.ActivityCount,
})
}
}
// 板块 + 各自帖子计数(一条 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]})
}
// 最新评论:JOIN 未封禁用户与已发布帖,一条 SQL 取齐
type latestCommentRow struct {
ID uint
Content string
CreatedAt time.Time
UID uint
Username string
Nickname string
Avatar string
PID uint
Title string
}
var commentRows []latestCommentRow
commentSQL := `
SELECT c.id, c.content, c.created_at,
u.id AS uid, u.username AS username, u.nickname AS nickname, u.avatar AS avatar,
p.id AS pid, p.title AS title
FROM comments c
JOIN users u ON u.id = c.user_id AND u.banned = ? AND u.deleted_at IS NULL
JOIN posts p ON p.id = c.post_id AND p.status = ? AND p.deleted_at IS NULL
WHERE c.status = ? AND c.deleted_at IS NULL
ORDER BY c.created_at DESC
LIMIT 6`
if err := s.db.Raw(commentSQL,
false, model.ContentStatusPublished, model.ContentStatusPublished).
Scan(&commentRows).Error; err != nil {
return nil, err
}
for _, r := range commentRows {
item := LatestCommentItem{
ID: r.ID,
Content: truncateText(r.Content, 60),
CreatedAt: r.CreatedAt,
}
item.User.ID = r.UID
item.User.Username = r.Username
item.User.Nickname = r.Nickname
item.User.Avatar = r.Avatar
item.Post.ID = r.PID
item.Post.Title = r.Title
data.LatestComments = append(data.LatestComments, item)
}
// 最新注册成员
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
}