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,