Files
mce/internal/service/post_service.go
Victor_Jay b9d90fe7ec 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 天有效期
2026-06-02 00:02:48 +08:00

416 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// mdPatterns Markdown 语法正则(用于生成纯文本摘要)
var mdPatterns = []*regexp.Regexp{
// 代码块 ```...```
regexp.MustCompile("(?s)```[^`]*```"),
// 行内代码 `...`
regexp.MustCompile("`[^`]+`"),
// 图片 ![alt](url)
regexp.MustCompile(`!\[.*?\]\(.*?\)`),
// 链接 [text](url)
regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`),
// 标题标记 #
regexp.MustCompile(`^#{1,6}\s+`),
// 粗体/斜体/删除线标记
regexp.MustCompile(`\*{1,3}|_{1,3}|~~`),
// 引用 >
regexp.MustCompile(`^>\s?`),
// 列表标记
regexp.MustCompile(`^[\s]*[-*+]\s+`),
regexp.MustCompile(`^[\s]*\d+\.\s+`),
// 分割线
regexp.MustCompile(`^[-*_]{3,}\s*$`),
}
// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符)
// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染
func generateExcerpt(md string) string {
text := md
// 按行处理,保留行结构
lines := strings.Split(text, "\n")
var cleaned []string
for _, line := range lines {
cleanedLine := line
for _, re := range mdPatterns {
if re == mdPatterns[2] {
// 链接保留文字: [text](url) → text
cleanedLine = re.ReplaceAllString(cleanedLine, "$1")
} else {
cleanedLine = re.ReplaceAllString(cleanedLine, "")
}
}
cleanedLine = strings.TrimSpace(cleanedLine)
if cleanedLine != "" {
cleaned = append(cleaned, cleanedLine)
}
}
result := strings.Join(cleaned, " ")
// 按 rune 截断,不切中文字符
const maxChars = 300
if utf8.RuneCountInString(result) > maxChars {
runes := []rune(result)
result = string(runes[:maxChars]) + "..."
}
return result
}
// PostService 帖子业务逻辑
type PostService struct {
repo postStore
ss *config.SiteSettings
notifier postNotifier
}
// NewPostService 构造函数
func NewPostService(repo postStore, ss *config.SiteSettings, notifier postNotifier) *PostService {
return &PostService{
repo: repo,
ss: ss,
notifier: notifier,
}
}
// auditEnabled 审核是否开启
func (s *PostService) auditEnabled() bool {
return s.ss.IsAuditEnabled()
}
// findPost 按 ID 查找帖子,区分"未找到"和"数据库错误"
func (s *PostService) findPost(id uint) (*model.Post, error) {
post, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPostNotFound
}
return nil, fmt.Errorf("查询帖子失败: %w", err)
}
return post, nil
}
// findPostWithAuthor 按 ID 查找帖子(含作者名),区分"未找到"和"数据库错误"
func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
post, err := s.repo.FindByIDWithAuthor(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPostNotFound
}
return nil, fmt.Errorf("查询帖子失败: %w", err)
}
return post, nil
}
// Create 创建帖子
// body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
}
post := &model.Post{
Title: title,
Body: body,
Excerpt: generateExcerpt(body),
UserID: userID,
Status: status,
AllowComment: true,
}
if err := s.repo.Create(post); err != nil {
return nil, err
}
return post, nil
}
// GetByID 按 ID 获取帖子(含作者名,仅 approved 公开可见)
func (s *PostService) GetByID(id uint) (*model.Post, error) {
return s.findPostWithAuthor(id)
}
// listPageable 分页查询公共逻辑:参数规范化 + nil 转空切片
func (s *PostService) listPageable(page, pageSize int, fn func(offset, limit int) ([]model.Post, int64, error)) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
posts, total, err := fn(p.Offset(), p.PageSize)
if posts == nil {
posts = []model.Post{}
}
return posts, total, err
}
// List 公开帖子列表(仅 approved
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindPageable(keyword, offset, limit)
})
}
// ListAdmin 管理后台帖子列表(全状态)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindAdminPageable(keyword, status, offset, limit)
})
}
// ListByUser 查询某用户指定状态的帖子(空 status=全状态)
func (s *PostService) ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindByUserIDAndStatus(userID, status, offset, limit)
})
}
// StudioOverview 创作中心数据概览
type StudioOverview struct {
TotalPosts int64 `json:"total_posts"`
DraftCount int64 `json:"draft_count"`
PendingCount int64 `json:"pending_count"`
ApprovedCount int64 `json:"approved_count"`
RejectedCount int64 `json:"rejected_count"`
}
// GetOverview 获取创作中心数据概览
func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
// 获取各状态文章数量(分页传 1 条只取 total
_, totalPosts, err := s.repo.FindByUserIDAndStatus(userID, "", 0, 1)
if err != nil {
return nil, err
}
_, draftCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusDraft, 0, 1)
_, pendingCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusPending, 0, 1)
_, approvedCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusApproved, 0, 1)
_, rejectedCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusRejected, 0, 1)
return &StudioOverview{
TotalPosts: totalPosts,
DraftCount: draftCount,
PendingCount: pendingCount,
ApprovedCount: approvedCount,
RejectedCount: rejectedCount,
}, nil
}
// Update 编辑帖子(权限在 controller 层检查)
func (s *PostService) Update(postID uint, title, body string) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
if post.Status == model.PostStatusPending || post.IsLocked {
return common.ErrPostCannotEdit
}
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
if post.Status == model.PostStatusApproved {
post.PendingTitle = title
post.PendingBody = body
post.RejectReason = "" // 清空旧退回理由
return s.repo.Update(post)
}
// 草稿/已退回:直接修改原文(现有逻辑)
post.Title = title
post.Body = body
post.Excerpt = generateExcerpt(body)
if post.Status == model.PostStatusRejected {
post.Status = model.PostStatusDraft
post.RejectReason = ""
}
return s.repo.Update(post)
}
// Delete 软删除(权限在 controller 层检查)
func (s *PostService) Delete(postID uint) error {
return s.repo.SoftDelete(postID)
}
// SubmitForAudit 提交审核draft → pending
func (s *PostService) SubmitForAudit(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
if post.IsLocked {
return common.ErrPostCannotEdit
}
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
return common.ErrPostCannotSubmit
}
post.Status = model.PostStatusPending
return s.repo.Update(post)
}
// Approve 审核通过pending → approved或修订审核通过复制 Pending 到正式字段
func (s *PostService) Approve(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
wasRevision := post.PendingBody != ""
// 修订审核:将待审内容覆盖到正式字段
if wasRevision {
post.Title = post.PendingTitle
post.Body = post.PendingBody
post.Excerpt = generateExcerpt(post.PendingBody)
post.PendingTitle = ""
post.PendingBody = ""
post.Status = model.PostStatusApproved
post.RejectReason = ""
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者修订审核通过
if s.notifier != nil {
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
}
return nil
}
// 首次审核通过
if post.Status != model.PostStatusPending {
return common.ErrPostCannotApprove
}
post.Status = model.PostStatusApproved
post.RejectReason = ""
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者审核通过
if s.notifier != nil {
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
}
return nil
}
// Reject 退回pending/approved → rejected修订退回则清空 Pending 保持 approved
func (s *PostService) Reject(postID uint, reason string) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
return common.ErrPostCannotReject
}
// 修订退回:清空待审修订,保持已发布内容不变
if post.PendingBody != "" {
post.PendingTitle = ""
post.PendingBody = ""
post.RejectReason = reason
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者修订被退回
if s.notifier != nil {
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
}
return nil
}
// 普通退回(首次审核)
post.Status = model.PostStatusRejected
post.RejectReason = reason
if err := s.repo.Update(post); err != nil {
return err
}
// 通知作者审核被退回
if s.notifier != nil {
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
}
return nil
}
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
func (s *PostService) Lock(postID uint, reason string) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
// 待审核帖子不允许锁定(审核流程中)
if post.Status == model.PostStatusPending {
return common.ErrPostCannotLock
}
post.IsLocked = true
post.LockReason = reason
return s.repo.Update(post)
}
// Unlock 解锁:清除 IsLocked 标志,恢复可编辑
func (s *PostService) Unlock(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
if !post.IsLocked {
return common.ErrPostCannotUnlock
}
post.IsLocked = false
post.LockReason = ""
return s.repo.Update(post)
}
// Restore 恢复软删除
func (s *PostService) Restore(postID uint) error {
err := s.repo.Restore(postID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrPostNotFound
}
return err
}
return nil
}
// ListPendingRevisions 查询有待审修订的帖子approved 且 PendingBody 不为空)
func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) {
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
return s.repo.FindPendingRevisions(keyword, offset, limit)
})
}
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+
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)
}