diff --git a/cmd/server/main.go b/cmd/server/main.go index b8e1861..4a9e03b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 7c3594d..cfb083a 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -25,7 +25,7 @@ type rateLimiter interface { ClearIP(ipKey string) } -// postUseCase PostController 对 PostService 的最小依赖(ISP:7 个方法) +// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法) type postUseCase interface { Create(userID uint, title, body string) (*model.Post, error) GetByID(id uint) (*model.Post, error) @@ -33,6 +33,8 @@ type postUseCase interface { Update(postID uint, title, body string) error Delete(postID uint) error SubmitForAudit(postID uint) error + RecordRead(userID, postID uint) + RecordGuestRead(visitorID string, postID uint) IsPostAccessible(userID uint, role string, postUserID uint) bool } diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index ef3b406..e93a44e 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -2,6 +2,8 @@ package controller import ( "bytes" + "crypto/rand" + "encoding/hex" "errors" "fmt" "image" @@ -109,6 +111,17 @@ func (ctrl *PostController) ShowPage(c *gin.Context) { return } + // 已登录用户阅读计数(已发布帖子,自动去重+防刷) + if uid > 0 && post.Status == model.PostStatusApproved { + ctrl.postService.RecordRead(uid, post.ID) + } + + // 访客阅读计数(基于 Cookie 标识去重+防刷) + if uid == 0 && post.Status == model.PostStatusApproved { + vid := ctrl.getVisitorID(c) + ctrl.postService.RecordGuestRead(vid, post.ID) + } + ctrl.applyShortcode(post) // Open Graph 社交分享数据 @@ -207,15 +220,27 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) { return } + uid, role, _ := common.GetGinUser(c) + // 权限控制:非 approved 帖子仅作者和 moderator+ 可见 if post.Status != model.PostStatusApproved { - uid, role, _ := common.GetGinUser(c) if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { common.Error(c, http.StatusNotFound, "帖子不存在") return } } + // 已登录用户阅读计数(已发布帖子,自动去重+防刷) + if uid > 0 && post.Status == model.PostStatusApproved { + ctrl.postService.RecordRead(uid, post.ID) + } + + // 访客阅读计数(基于 Cookie 标识去重+防刷) + if uid == 0 && post.Status == model.PostStatusApproved { + vid := ctrl.getVisitorID(c) + ctrl.postService.RecordGuestRead(vid, post.ID) + } + ctrl.applyShortcode(post) common.Ok(c, post) @@ -343,6 +368,27 @@ func (ctrl *PostController) UploadImage(c *gin.Context) { common.VditorUploadOk(c, map[string]string{header.Filename: url}) } +// visitorCookieName Cookie 名称 +const visitorCookieName = "visitor_id" + +// visitorCookieMaxAge 访客 Cookie 有效期(30 天) +const visitorCookieMaxAge = 30 * 24 * 3600 + +// getVisitorID 获取或创建访客标识 Cookie +func (ctrl *PostController) getVisitorID(c *gin.Context) string { + if cookie, err := c.Cookie(visitorCookieName); err == nil && cookie != "" { + return cookie + } + // 生成 16 字节随机 hex(32 字符) + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "" + } + vid := hex.EncodeToString(b) + c.SetCookie(visitorCookieName, vid, visitorCookieMaxAge, "/", "", false, true) + return vid +} + // applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符 func (ctrl *PostController) applyShortcode(post *model.Post) { if ctrl.shortcodeSvc != nil { diff --git a/internal/model/post.go b/internal/model/post.go index 5b6257d..0acf130 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -25,6 +25,7 @@ type Post struct { LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器) DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见) FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器) + ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重) DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/model/post_read_log.go b/internal/model/post_read_log.go new file mode 100644 index 0000000..5db807f --- /dev/null +++ b/internal/model/post_read_log.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// PostReadLog 文章阅读记录(已登录用户每篇文章只记录一次) +type PostReadLog struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"user_id"` + PostID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"post_id"` + ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"` +} + +// PostGuestReadLog 访客阅读记录(未登录用户,基于 Cookie 标识去重) +type PostGuestReadLog struct { + ID uint `gorm:"primarykey" json:"id"` + VisitorID string `gorm:"uniqueIndex:idx_visitor_post_read;type:varchar(64);not null" json:"visitor_id"` + PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"` + ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"` +} + diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 5e2de54..4b1a19f 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -1,9 +1,12 @@ package repository import ( + "time" + "metazone.cc/metalab/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // PostRepo 帖子数据访问 @@ -189,3 +192,52 @@ func (r *PostRepo) Restore(id uint) error { } return nil } + +// readCooldown 两次阅读记录最小间隔 +const readCooldown = 2 * time.Second + +// RecordRead 记录已登录用户阅读文章(防刷量:取去重+冷却) +// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中 +func (r *PostRepo) RecordRead(userID, postID uint) (bool, error) { + // 冷却检查:同用户两次阅读之间至少间隔 2 秒 + var lastRead model.PostReadLog + if err := r.db.Where("user_id = ?", userID). + Order("read_at DESC").First(&lastRead).Error; err == nil { + if time.Since(lastRead.ReadAt) < readCooldown { + return false, nil + } + } + + result := r.db.Clauses(clause.OnConflict{DoNothing: true}). + Create(&model.PostReadLog{UserID: userID, PostID: postID}) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// RecordGuestRead 记录访客阅读文章(基于 Cookie visitor_id 去重+冷却) +// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中 +func (r *PostRepo) RecordGuestRead(visitorID string, postID uint) (bool, error) { + // 冷却检查:同访客两次阅读之间至少间隔 2 秒 + var lastRead model.PostGuestReadLog + if err := r.db.Where("visitor_id = ?", visitorID). + Order("read_at DESC").First(&lastRead).Error; err == nil { + if time.Since(lastRead.ReadAt) < readCooldown { + return false, nil + } + } + + result := r.db.Clauses(clause.OnConflict{DoNothing: true}). + Create(&model.PostGuestReadLog{VisitorID: visitorID, PostID: postID}) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// IncrementViewsCount 阅读数 +1 +func (r *PostRepo) IncrementViewsCount(postID uint) error { + return r.db.Model(&model.Post{}).Where("id = ?", postID). + Update("views_count", gorm.Expr("views_count + 1")).Error +} diff --git a/internal/service/post_service.go b/internal/service/post_service.go index b24ca98..fb20820 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -395,3 +395,21 @@ func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ( func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool { return userID == postUserID || model.HasMinRole(role, model.RoleModerator) } + +// RecordRead 记录已登录用户阅读文章(已登录去重 + 防刷冷却) +func (s *PostService) RecordRead(userID, postID uint) { + firstRead, err := s.repo.RecordRead(userID, postID) + if err != nil || !firstRead { + return + } + _ = s.repo.IncrementViewsCount(postID) +} + +// RecordGuestRead 记录访客阅读文章(Cookie 标识去重 + 防刷冷却) +func (s *PostService) RecordGuestRead(visitorID string, postID uint) { + firstRead, err := s.repo.RecordGuestRead(visitorID, postID) + if err != nil || !firstRead { + return + } + _ = s.repo.IncrementViewsCount(postID) +} diff --git a/internal/service/repository.go b/internal/service/repository.go index 681d9bc..ca6a73c 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -41,6 +41,9 @@ type postStore interface { Update(post *model.Post) error SoftDelete(id uint) error Restore(id uint) error + RecordRead(userID, postID uint) (bool, error) + RecordGuestRead(visitorID string, postID uint) (bool, error) + IncrementViewsCount(postID uint) error } // spaceUserStore SpaceService 所需的最小用户仓储接口