feat: 新增站点公告系统与多项体验优化

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

View File

@@ -1,7 +1,7 @@
package service
import (
"strings"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/model"
@@ -17,21 +17,26 @@ func NewOverviewService(db *gorm.DB) *OverviewService {
return &OverviewService{db: db}
}
// OverviewStats 社区整体统计
// OverviewStats 社区整体统计(社区脉搏卡)
type OverviewStats struct {
Posts int64 `json:"posts"`
Users int64 `json:"users"`
Comments int64 `json:"comments"`
TodayPosts int64 `json:"today_posts"`
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 近 14 天活跃用户
// ActiveUser 本周活跃用户(发帖数 / 回复数分列)
type ActiveUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ActivityCount int64 `json:"activity_count"`
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 展平)
@@ -40,21 +45,13 @@ type BoardCount struct {
PostCount int64 `json:"post_count"`
}
// LatestCommentItem 首页"最新评论"条目(纯文本摘要 + 所属帖子)
type LatestCommentItem struct {
// AnnouncementItem 首页"站点公告"条目(标签/日期/标题)
type AnnouncementItem struct {
ID uint `json:"id"`
Content string `json:"content"`
Title string `json:"title"`
Tag string `json:"tag"`
TagColor string `json:"tag_color"`
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 最新注册成员
@@ -68,40 +65,46 @@ type NewUserItem struct {
// 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"`
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"`
}
// 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]) + "…"
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 // 周日归到本周末尾
}
return s
d := t.AddDate(0, 0, -(wd - 1))
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, t.Location())
}
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{},
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())
since := now.Add(-activeUserWindow)
weekStart := startOfWeek(now)
onlineSince := now.Add(-onlineWindow)
// 统计
postQ := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
@@ -119,6 +122,24 @@ func (s *OverviewService) Get() (*OverviewData, error) {
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
@@ -129,27 +150,30 @@ func (s *OverviewService) Get() (*OverviewData, error) {
}
data.Hot = toPostListItems(hotPosts)
// 活跃用户:近 14 天发帖+评论合并计数,JOIN 用户过滤封禁/软删
// 本周活跃用户:发帖/评论分别计数,JOIN 用户过滤封禁/软删
type activityRow struct {
UserID uint
ActivityCount int64
UserID uint
PostCount int64
CommentCount int64
}
var rows []activityRow
activitySQL := `
SELECT a.user_id AS user_id, COUNT(*) AS activity_count
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 FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
SELECT user_id, 1 AS is_post 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
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 activity_count DESC, a.user_id ASC
ORDER BY COUNT(*) DESC, a.user_id ASC
LIMIT 5`
if err := s.db.Raw(activitySQL,
model.ContentStatusPublished, since,
model.ContentStatusPublished, since,
model.ContentStatusPublished, weekStart,
model.ContentStatusPublished, weekStart,
false).Scan(&rows).Error; err != nil {
return nil, err
}
@@ -172,11 +196,12 @@ LIMIT 5`
continue
}
data.ActiveUsers = append(data.ActiveUsers, ActiveUser{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
ActivityCount: r.ActivityCount,
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
PostCount: r.PostCount,
CommentCount: r.CommentCount,
})
}
}
@@ -205,47 +230,20 @@ LIMIT 5`
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 {
// 站点公告(已发布,最新 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 _, 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)
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,
})
}
// 最新注册成员
@@ -267,3 +265,26 @@ LIMIT 6`
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
}