feat: 新增文章阅读量计数(已登录去重 + 访客防刷)
- Post 模型新增 ViewsCount 冗余计数字段 - 新建 PostReadLog 表(user_id + post_id 唯一索引),已登录用户每篇文章只计一次 - 新建 PostGuestReadLog 表(visitor_id + post_id 唯一索引),访客基于 Cookie 标识去重 - Repository 层新增 RecordRead / RecordGuestRead / IncrementViewsCount - Service 层编排去重-计数逻辑,计数失败不影响页面渲染 - Controller 层 ShowPage + ShowAPI 触发计数,仅 approved 状态生效 - 双层防刷:唯一索引去重 + 2 秒冷却间隔 - 访客 Cookie (visitor_id) 基于 crypto/rand 生成,30 天有效期
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
20
internal/model/post_read_log.go
Normal file
20
internal/model/post_read_log.go
Normal file
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 所需的最小用户仓储接口
|
||||
|
||||
Reference in New Issue
Block a user