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

@@ -0,0 +1,111 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ===== 公开接口 =====
// AnnouncementsList 已发布公告列表(首页右栏 / 公告页)
func (h *Handlers) AnnouncementsList(c *gin.Context) {
list, err := h.Announcement.ListPublished(10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
return
}
c.JSON(http.StatusOK, gin.H{"announcements": list})
}
// AnnouncementDetail 已发布公告详情
func (h *Handlers) AnnouncementDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
return
}
a, err := h.Announcement.GetPublished(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
return
}
c.JSON(http.StatusOK, gin.H{"announcement": a})
}
// ===== 管理接口(RequireAdmin 兜底,前端不做权限判定) =====
// AdminListAnnouncements 全部公告(含草稿)
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
list, err := h.Announcement.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
return
}
c.JSON(http.StatusOK, gin.H{"announcements": list})
}
// AdminCreateAnnouncement 新建公告
func (h *Handlers) AdminCreateAnnouncement(c *gin.Context) {
var in service.AnnouncementInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
a, err := h.Announcement.Create(&in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"announcement": a})
}
// AdminUpdateAnnouncement 编辑公告
func (h *Handlers) AdminUpdateAnnouncement(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
return
}
var in service.AnnouncementInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
a, err := h.Announcement.Update(uint(id), &in)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"announcement": a})
}
// AdminDeleteAnnouncement 删除公告
func (h *Handlers) AdminDeleteAnnouncement(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
return
}
if err := h.Announcement.Delete(uint(id)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -103,7 +103,7 @@ type LoginRequest struct {
Password string `json:"password" binding:"required"`
}
// Register 用户注册
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
func (h *Handlers) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -115,9 +115,25 @@ func (h *Handlers) Register(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 自动登录:走与 Login 相同的 token 签发链路
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password)
if err != nil {
// 签发失败不应让注册白做:仍返回成功,让前端引导去登录页
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
})
return
}
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
"user": gin.H{
"id": loginUser.ID,
"username": loginUser.Username,
"nickname": loginUser.Nickname,
"avatar": loginUser.Avatar,
"role": loginUser.Role,
},
})
}

View File

@@ -9,27 +9,37 @@ import (
"github.com/gin-gonic/gin"
)
// PostComments 获取帖子评论
// PostComments 获取帖子评论树(楼层分页:主评论算楼层,每楼附带全部子回复,时间正序)
func (h *Handlers) PostComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
comments, err := h.Comment.ListByPost(uint(id))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"comments": comments})
c.JSON(http.StatusOK, gin.H{
"comments": comments,
"total": floors, // 楼层数(主评论数)→ 前端分页与楼层号计算
"total_comments": totalComments, // 全部评论数(含回复)→ 展示徽标
"page": page,
"size": size,
})
}
// CreateCommentRequest 评论请求
type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1"`
Content string `json:"content" binding:"required,min=1"`
ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论)
}
// CreateComment 创建评论
// CreateComment 创建评论(主评论或子回复)
func (h *Handlers) CreateComment(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -42,14 +52,19 @@ func (h *Handlers) CreateComment(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
comment, err := h.Comment.Create(claims.ID, uint(id), req.Content)
comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 通知帖子作者(排除自己评论自己的帖子)
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
if parent == nil {
// 主评论:通知帖子作者(排除自己评论自己的帖子)
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
}
} else {
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)
}
c.JSON(http.StatusOK, gin.H{"comment": comment})
}

View File

@@ -16,4 +16,5 @@ type Handlers struct {
Notification *service.NotificationService
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
}

View File

@@ -29,12 +29,12 @@ func (h *Handlers) Overview(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"latest_comments": data.LatestComments,
"new_users": data.NewUsers,
"checkin": data.Checkin,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"new_users": data.NewUsers,
"checkin": data.Checkin,
})
}

View File

@@ -65,6 +65,18 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
})
}
// 站点公告
var anns []model.Announcement
model.DB.Where("published = ?", true).Order("created_at DESC").Limit(200).Find(&anns)
for _, a := range anns {
entries = append(entries, urlEntry{
Loc: fmt.Sprintf("%s/announcement/%d", siteBaseURL, a.ID),
Lastmod: a.UpdatedAt.Format("2006-01-02"),
Changefreq: "weekly",
Priority: "0.5",
})
}
us := urlset{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: entries,

View File

@@ -45,6 +45,13 @@ func (h *Handlers) UserProfile(c *gin.Context) {
postCount, _ := h.Post.CountByUser(user.ID)
commentCount, _ := h.Comment.CountByUser(user.ID)
// 签到积分概览(失败不阻塞资料返回)
pointsTotal, streak := 0, 0
if cs, err := h.Checkin.Status(user.ID); err == nil && cs != nil {
pointsTotal = cs.TotalPoints
streak = cs.Streak
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
@@ -57,6 +64,8 @@ func (h *Handlers) UserProfile(c *gin.Context) {
"stats": gin.H{
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
},
"posts": posts,
"posts_total": postsTotal,

View File

@@ -86,6 +86,8 @@ func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool)
if _, err := m.auth.ValidateClaims(claims); err != nil {
return nil, false
}
// 异步刷新在线心跳(SQL 每 60s 限频一次),不阻塞请求
go m.auth.TouchLastSeen(claims.ID)
return claims, true
}

View File

@@ -31,6 +31,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SiteSetting{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}

View File

@@ -38,6 +38,7 @@ type User struct {
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@@ -98,11 +99,14 @@ type Post struct {
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// Comment 评论
// Comment 评论(主评论 = 楼层,ParentID 为空;子评论挂 root_id 对应楼层下)
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层)
RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:16;default:published;index" json:"status"`
CreatedAt time.Time `json:"created_at"`
@@ -132,6 +136,7 @@ type Checkin struct {
// 通知类型
const (
NotificationTypeComment = "comment" // 评论了你的帖子
NotificationTypeReply = "reply" // 回复了你的评论
NotificationTypeLike = "like" // 点赞了你的帖子
)
@@ -150,3 +155,23 @@ type Notification struct {
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
}
// Announcement 站点公告(后台文章管理:支持草稿、标签与标签预设色)
type Announcement struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tag string `gorm:"size:32;not null;default:公告" json:"tag"`
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"size:255;not null;default:''" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -45,6 +45,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
notifSvc := service.NewNotificationService(model.DB)
overviewSvc := service.NewOverviewService(model.DB)
checkinSvc := service.NewCheckinService(model.DB)
announcementSvc := service.NewAnnouncementService(model.DB)
limiter := service.DefaultRateLimiter()
h := &handler.Handlers{
@@ -57,6 +58,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Notification: notifSvc,
OverviewSvc: overviewSvc,
Checkin: checkinSvc,
Announcement: announcementSvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
@@ -77,6 +79,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/posts/:id/comments", h.PostComments)
pubAPI.GET("/users/:id", h.UserProfile)
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
@@ -112,6 +116,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/dashboard", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "admin dashboard"})
})
// 站点公告文章管理
adminAPI.GET("/announcements", h.AdminListAnnouncements)
adminAPI.POST("/announcements", h.AdminCreateAnnouncement)
adminAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
}
r.NoRoute(func(c *gin.Context) {

View 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
}

View File

@@ -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
}

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
}

View 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
}