feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
149
backend/service/announcement.go
Normal file
149
backend/service/announcement.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 公告标签允许使用的预设色(服务端白名单,禁止透传任意颜色值)
|
||||
var allowedTagColors = map[string]bool{
|
||||
"blue": true, "green": true, "orange": true,
|
||||
"red": true, "purple": true, "gray": true,
|
||||
}
|
||||
|
||||
// AnnouncementService 站点公告服务
|
||||
type AnnouncementService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAnnouncementService(db *gorm.DB) *AnnouncementService {
|
||||
return &AnnouncementService{db: db}
|
||||
}
|
||||
|
||||
// AnnouncementInput 创建/更新公告的入参
|
||||
type AnnouncementInput struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tag string `json:"tag"`
|
||||
TagColor string `json:"tag_color"`
|
||||
Published *bool `json:"published"`
|
||||
}
|
||||
|
||||
// normalize 清洗并校验入参,返回可直接落库的字段
|
||||
func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.Title == "" {
|
||||
return errors.New("标题不能为空")
|
||||
}
|
||||
if len([]rune(in.Title)) > 200 {
|
||||
return errors.New("标题不能超过 200 字")
|
||||
}
|
||||
in.Content = strings.TrimSpace(in.Content)
|
||||
if in.Content == "" {
|
||||
return errors.New("内容不能为空")
|
||||
}
|
||||
|
||||
in.Tag = strings.TrimSpace(in.Tag)
|
||||
if in.Tag == "" {
|
||||
in.Tag = "公告"
|
||||
}
|
||||
if len([]rune(in.Tag)) > 32 {
|
||||
return errors.New("标签不能超过 32 字")
|
||||
}
|
||||
|
||||
in.TagColor = strings.ToLower(strings.TrimSpace(in.TagColor))
|
||||
if in.TagColor == "" {
|
||||
if existing != nil && existing.TagColor != "" {
|
||||
in.TagColor = existing.TagColor
|
||||
} else {
|
||||
in.TagColor = "blue"
|
||||
}
|
||||
}
|
||||
if !allowedTagColors[in.TagColor] {
|
||||
return errors.New("不支持的标签颜色")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyTo 把入参写入模型(Published 为指针以区分"未传"与"显式 false")
|
||||
func (in *AnnouncementInput) applyTo(a *model.Announcement) {
|
||||
a.Title = in.Title
|
||||
a.Content = in.Content
|
||||
a.Tag = in.Tag
|
||||
a.TagColor = in.TagColor
|
||||
if in.Published != nil {
|
||||
a.Published = *in.Published
|
||||
}
|
||||
}
|
||||
|
||||
// ListPublished 已发布公告(首页右栏 / 公开列表),按发布时间倒序
|
||||
func (s *AnnouncementService) ListPublished(limit int) ([]model.Announcement, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
var list []model.Announcement
|
||||
err := s.db.Where("published = ?", true).
|
||||
Order("created_at DESC, id DESC").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetPublished 取单条已发布公告(详情页);草稿/不存在返回 gorm.ErrRecordNotFound
|
||||
func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error) {
|
||||
var a model.Announcement
|
||||
err := s.db.Where("published = ?", true).First(&a, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// ListAll 管理后台:含草稿,按更新时间倒序
|
||||
func (s *AnnouncementService) ListAll() ([]model.Announcement, error) {
|
||||
var list []model.Announcement
|
||||
err := s.db.Order("created_at DESC, id DESC").Limit(100).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Create 创建公告
|
||||
func (s *AnnouncementService) Create(in *AnnouncementInput) (*model.Announcement, error) {
|
||||
if err := in.normalize(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &model.Announcement{Published: true}
|
||||
in.applyTo(a)
|
||||
if err := s.db.Create(a).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Update 更新公告
|
||||
func (s *AnnouncementService) Update(id uint, in *AnnouncementInput) (*model.Announcement, error) {
|
||||
var a model.Announcement
|
||||
if err := s.db.First(&a, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := in.normalize(&a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.applyTo(&a)
|
||||
if err := s.db.Save(&a).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// Delete 删除公告(软删除)
|
||||
func (s *AnnouncementService) Delete(id uint) error {
|
||||
result := s.db.Delete(&model.Announcement{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,27 +18,114 @@ func NewCommentService(db *gorm.DB) *CommentService {
|
||||
return &CommentService{db: db}
|
||||
}
|
||||
|
||||
// ListByPost 获取帖子的评论列表
|
||||
func (s *CommentService) ListByPost(postID uint) ([]model.Comment, error) {
|
||||
var comments []model.Comment
|
||||
err := s.db.Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
||||
type CommentNode struct {
|
||||
model.Comment
|
||||
Replies []CommentNode `json:"replies,omitempty"`
|
||||
}
|
||||
|
||||
// Create 创建评论
|
||||
func (s *CommentService) Create(userID, postID uint, content string) (*model.Comment, error) {
|
||||
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
|
||||
const maxReplyDepth = 10
|
||||
|
||||
// ListFloorPaged 楼层分页获取帖子评论树:
|
||||
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
|
||||
// 返回:树根列表、楼层数、全部评论数(含回复)。
|
||||
func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentNode, int64, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
base := s.db.Model(&model.Comment{}).
|
||||
Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished)
|
||||
|
||||
var floors, totalComments int64
|
||||
if err := base.Session(&gorm.Session{}).
|
||||
Where("parent_id IS NULL").Count(&floors).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if err := base.Session(&gorm.Session{}).Count(&totalComments).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
// 1. 本页楼层(主评论)
|
||||
var roots []model.Comment
|
||||
if err := base.Session(&gorm.Session{}).
|
||||
Where("parent_id IS NULL").
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
Find(&roots).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
return []CommentNode{}, floors, totalComments, nil
|
||||
}
|
||||
|
||||
// 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询)
|
||||
rootIDs := make([]uint, len(roots))
|
||||
for i, r := range roots {
|
||||
rootIDs[i] = r.ID
|
||||
}
|
||||
var replies []model.Comment
|
||||
if err := s.db.Where("root_id IN (?) AND status = ?", rootIDs, model.ContentStatusPublished).
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Find(&replies).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
// 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部
|
||||
children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序)
|
||||
for _, r := range replies {
|
||||
if r.ParentID != nil {
|
||||
children[*r.ParentID] = append(children[*r.ParentID], r)
|
||||
}
|
||||
}
|
||||
placed := make(map[uint]bool, len(roots)+len(replies))
|
||||
var build func(pid uint) []CommentNode
|
||||
build = func(pid uint) []CommentNode {
|
||||
out := []CommentNode{}
|
||||
for _, c := range children[pid] {
|
||||
out = append(out, CommentNode{Comment: c, Replies: build(c.ID)})
|
||||
placed[c.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
result := make([]CommentNode, len(roots))
|
||||
for i := range roots {
|
||||
result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)}
|
||||
placed[roots[i].ID] = true
|
||||
}
|
||||
// 防御性兜底:父评论不在树中的回复 → 挂到所属楼层根部,保证不丢内容
|
||||
for _, r := range replies {
|
||||
if placed[r.ID] || r.RootID == nil {
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i].ID == *r.RootID {
|
||||
result[i].Replies = append(result[i].Replies, CommentNode{Comment: r, Replies: build(r.ID)})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, floors, totalComments, nil
|
||||
}
|
||||
|
||||
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)
|
||||
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
|
||||
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint) (*model.Comment, *model.Comment, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("评论内容不能为空")
|
||||
return nil, nil, errors.New("评论内容不能为空")
|
||||
}
|
||||
|
||||
// 检查帖子是否存在且未锁定评论
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
return nil, nil, errors.New("帖子不存在")
|
||||
}
|
||||
|
||||
comment := &model.Comment{
|
||||
@@ -47,14 +134,36 @@ func (s *CommentService) Create(userID, postID uint, content string) (*model.Com
|
||||
Content: content,
|
||||
Status: model.ContentStatusPublished,
|
||||
}
|
||||
|
||||
// 子回复:校验父评论(同帖、已发布),继承楼层根与层级
|
||||
var parent *model.Comment
|
||||
if parentID != nil {
|
||||
parent = &model.Comment{}
|
||||
if err := s.db.
|
||||
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
|
||||
First(parent).Error; err != nil {
|
||||
return nil, nil, errors.New("回复的评论不存在")
|
||||
}
|
||||
if parent.Depth >= maxReplyDepth {
|
||||
return nil, nil, errors.New("回复层级过深")
|
||||
}
|
||||
rootID := parent.ID
|
||||
if parent.RootID != nil {
|
||||
rootID = *parent.RootID
|
||||
}
|
||||
comment.ParentID = parentID
|
||||
comment.RootID = &rootID
|
||||
comment.Depth = parent.Depth + 1
|
||||
}
|
||||
|
||||
if err := s.db.Create(comment).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||
// 预加载用户
|
||||
s.db.Preload("User").First(comment, comment.ID)
|
||||
return comment, nil
|
||||
return comment, parent, nil
|
||||
}
|
||||
|
||||
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
|
||||
@@ -101,7 +210,7 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Delete 删除评论(仅作者或管理员可操作)
|
||||
// Delete 删除评论(仅作者或管理员可操作),级联软删整棵子树
|
||||
func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||
var comment model.Comment
|
||||
if err := s.db.First(&comment, commentID).Error; err != nil {
|
||||
@@ -111,12 +220,38 @@ func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||
if comment.UserID != userID && role != RoleAdmin {
|
||||
return errors.New("无权限删除此评论")
|
||||
}
|
||||
// 软删除
|
||||
if err := s.db.Delete(&comment).Error; err != nil {
|
||||
// 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛)
|
||||
var all []model.Comment
|
||||
if comment.RootID != nil {
|
||||
s.db.Where("root_id = ?", *comment.RootID).Find(&all)
|
||||
} else {
|
||||
s.db.Where("root_id = ?", comment.ID).Find(&all)
|
||||
}
|
||||
descendants := map[uint]bool{comment.ID: true}
|
||||
for i := 0; i <= maxReplyDepth; i++ {
|
||||
added := false
|
||||
for _, c := range all {
|
||||
if c.ParentID == nil || descendants[c.ID] {
|
||||
continue
|
||||
}
|
||||
if descendants[*c.ParentID] {
|
||||
descendants[c.ID] = true
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if !added {
|
||||
break
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(descendants))
|
||||
for id := range descendants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// 软删子树并按实际条数递减帖子评论数
|
||||
if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", len(ids)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
15
backend/service/presence.go
Normal file
15
backend/service/presence.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package service
|
||||
|
||||
import "time"
|
||||
|
||||
// TouchLastSeen 更新用户最近活跃时间(在线统计)。
|
||||
// SQL 条件限频:每用户每 60 秒最多真正写一次,其余为命中 0 行的空更新;
|
||||
// 调用方以 goroutine 异步触发,不增加请求链路延迟。
|
||||
func (s *AuthService) TouchLastSeen(userID uint) {
|
||||
threshold := time.Now().Add(-time.Minute)
|
||||
_ = s.db.Exec(
|
||||
`UPDATE users SET last_seen_at = NOW()
|
||||
WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
|
||||
userID, threshold,
|
||||
).Error
|
||||
}
|
||||
Reference in New Issue
Block a user