diff --git a/backend/handler/announcement.go b/backend/handler/announcement.go new file mode 100644 index 0000000..3c5fcef --- /dev/null +++ b/backend/handler/announcement.go @@ -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}) +} diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 7d47bdd..8758cca 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -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, + }, }) } diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 939a0f1..e39a8ae 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -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}) } diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 1257058..6c72174 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -16,4 +16,5 @@ type Handlers struct { Notification *service.NotificationService OverviewSvc *service.OverviewService Checkin *service.CheckinService + Announcement *service.AnnouncementService } diff --git a/backend/handler/overview.go b/backend/handler/overview.go index b1cf4e0..949f7eb 100644 --- a/backend/handler/overview.go +++ b/backend/handler/overview.go @@ -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, }) } diff --git a/backend/handler/seo.go b/backend/handler/seo.go index e0a2b69..3beaf27 100644 --- a/backend/handler/seo.go +++ b/backend/handler/seo.go @@ -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, diff --git a/backend/handler/user.go b/backend/handler/user.go index 31e83c2..e3d85cb 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -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, diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index c5f0d43..e202890 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -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 } diff --git a/backend/model/db.go b/backend/model/db.go index 8233f11..2430299 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -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) } diff --git a/backend/model/models.go b/backend/model/models.go index 7f5190d..d212d21 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -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"` +} diff --git a/backend/router/router.go b/backend/router/router.go index 5dcb7f6..cf85284 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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) { diff --git a/backend/service/announcement.go b/backend/service/announcement.go new file mode 100644 index 0000000..771221b --- /dev/null +++ b/backend/service/announcement.go @@ -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 +} diff --git a/backend/service/comment.go b/backend/service/comment.go index edf566a..c6fea23 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -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 } diff --git a/backend/service/overview.go b/backend/service/overview.go index 680df05..41f93a2 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -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 +} diff --git a/backend/service/presence.go b/backend/service/presence.go new file mode 100644 index 0000000..8a0175b --- /dev/null +++ b/backend/service/presence.go @@ -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 +} diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx new file mode 100644 index 0000000..6303d93 --- /dev/null +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { + Plus, + Pencil, + Trash2, + Loader2, + Save, + X, + ExternalLink, + Megaphone, + FileText, +} from "lucide-react"; +import { + apiAdminListAnnouncements, + apiAdminCreateAnnouncement, + apiAdminUpdateAnnouncement, + apiAdminDeleteAnnouncement, + type Announcement, + type AnnouncementColor, + type AnnouncementInput, +} from "@/lib/api"; +import { formatDate } from "@/lib/format"; +import { toast } from "@/lib/toast"; + +const COLORS: { value: AnnouncementColor; label: string }[] = [ + { value: "blue", label: "蓝色" }, + { value: "green", label: "绿色" }, + { value: "orange", label: "橙色" }, + { value: "red", label: "红色" }, + { value: "purple", label: "紫色" }, + { value: "gray", label: "灰色" }, +]; + +type FormState = AnnouncementInput & { id: number | null }; + +const EMPTY_FORM: FormState = { + id: null, + title: "", + content: "", + tag: "公告", + tag_color: "blue", + published: true, +}; + +export default function AnnouncementAdmin() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [form, setForm] = useState(null); + const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await apiAdminListAnnouncements(); + setList(res.announcements); + setLoadError(""); + } catch (e) { + setLoadError(e instanceof Error ? e.message : "加载公告失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const startCreate = () => setForm({ ...EMPTY_FORM }); + const startEdit = (a: Announcement) => + setForm({ + id: a.id, + title: a.title, + content: a.content, + tag: a.tag, + tag_color: a.tag_color, + published: a.published, + }); + + async function save() { + if (!form) return; + const payload: AnnouncementInput = { + title: form.title.trim(), + content: form.content.trim(), + tag: form.tag.trim() || "公告", + tag_color: form.tag_color, + published: form.published, + }; + if (!payload.title || !payload.content) { + toast("标题和内容不能为空"); + return; + } + setSaving(true); + try { + if (form.id == null) { + await apiAdminCreateAnnouncement(payload); + toast("公告已发布", "ok"); + } else { + await apiAdminUpdateAnnouncement(form.id, payload); + toast("公告已更新", "ok"); + } + setForm(null); + await load(); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + } + + async function remove(a: Announcement) { + if (!window.confirm(`确定删除公告「${a.title}」吗?此操作不可撤销。`)) return; + setDeletingId(a.id); + try { + await apiAdminDeleteAnnouncement(a.id); + setList((l) => l.filter((x) => x.id !== a.id)); + if (form?.id === a.id) setForm(null); + toast("公告已删除", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + return ( +
+ {/* 页头 */} +
+
+

+ + + + 站点公告管理 +

+

发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色

+
+ {form?.id == null && ( + + )} +
+ +
+ {/* 编辑/新建表单 */} + {form ? ( +
+

+ {form.id == null ? "新建公告" : "编辑公告"} +

+ + + setForm({ ...form, title: e.target.value })} + placeholder="请输入公告标题" + /> + +
+
+ + setForm({ ...form, tag: e.target.value })} + placeholder="公告" + /> +
+
+

标签颜色

+
+ {COLORS.map((c) => ( +
+
+
+ +

发布状态

+
+ + +
+ + +