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:
2026-06-02 00:02:48 +08:00
parent b869602185
commit b9d90fe7ec
8 changed files with 145 additions and 3 deletions

View File

@ -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 字节随机 hex32 字符)
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 {