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:
@ -27,7 +27,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
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)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ type rateLimiter interface {
|
|||||||
ClearIP(ipKey string)
|
ClearIP(ipKey string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:7 个方法)
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
||||||
type postUseCase interface {
|
type postUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
Create(userID uint, title, body string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
@ -33,6 +33,8 @@ type postUseCase interface {
|
|||||||
Update(postID uint, title, body string) error
|
Update(postID uint, title, body string) error
|
||||||
Delete(postID uint) error
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
|
RecordRead(userID, postID uint)
|
||||||
|
RecordGuestRead(visitorID string, postID uint)
|
||||||
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@ -109,6 +111,17 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
|
|||||||
return
|
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)
|
ctrl.applyShortcode(post)
|
||||||
|
|
||||||
// Open Graph 社交分享数据
|
// Open Graph 社交分享数据
|
||||||
@ -207,15 +220,27 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uid, role, _ := common.GetGinUser(c)
|
||||||
|
|
||||||
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
||||||
if post.Status != model.PostStatusApproved {
|
if post.Status != model.PostStatusApproved {
|
||||||
uid, role, _ := common.GetGinUser(c)
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
return
|
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)
|
ctrl.applyShortcode(post)
|
||||||
|
|
||||||
common.Ok(c, 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})
|
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 占位符
|
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
||||||
func (ctrl *PostController) applyShortcode(post *model.Post) {
|
func (ctrl *PostController) applyShortcode(post *model.Post) {
|
||||||
if ctrl.shortcodeSvc != nil {
|
if ctrl.shortcodeSvc != nil {
|
||||||
|
|||||||
@ -25,6 +25,7 @@ type Post struct {
|
|||||||
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
||||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||||
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||||
|
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostRepo 帖子数据访问
|
// PostRepo 帖子数据访问
|
||||||
@ -189,3 +192,52 @@ func (r *PostRepo) Restore(id uint) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
||||||
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
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
|
Update(post *model.Post) error
|
||||||
SoftDelete(id uint) error
|
SoftDelete(id uint) error
|
||||||
Restore(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 所需的最小用户仓储接口
|
// spaceUserStore SpaceService 所需的最小用户仓储接口
|
||||||
|
|||||||
Reference in New Issue
Block a user