Files
mce/internal/service/post_service.go
Victor_Jay 97adf54d6d fix: golangci-lint 零告警通过 (57→0) + gofumpt/goimports 全量格式化
## CI 修复 (P0/P1)

P0 — 编译阻塞:
  - interfaces.go: postUseCase ISP 接口移除不用的 Create/Update/Delete

P1 — 必须修复:
  - ST1000: 为 13 个包添加包注释 (common/config/model/service/...)
  - ST1005: redis_store.go 全部错误消息改为小写开头
  - errcheck (19处): defer Close()→闭包忽略, notifier.Create→_=, r.Run→检查error
  - errorlint (9处): switch-on-error→errors.Is 链, ==→errors.Is
  - ST1020/ST1022: 导出符号注释以符号名开头

P2 — 安全评审:
  - gosec (12处): G203/G301/G304/G306 添加 nolint 注释并附理由

P3 — 清理:
  - unused: 移除 hasUnicode/energyStore/current/energyAdminUseCase
  - gofumpt + goimports 全量格式化 (35+ 文件)
2026-06-22 02:27:35 +08:00

246 lines
7.3 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"
"strconv"
"time"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config"
"metazone.cc/mce/internal/model"
"gorm.io/gorm"
)
// PostService 帖子业务逻辑
type PostService struct {
repo postStore
ss *config.SiteSettings
notifier postNotifier
userRepo userExpReader
onHomePageChanged func()
}
// NewPostService 构造函数
func NewPostService(repo postStore, ss *config.SiteSettings, notifier postNotifier, userRepo userExpReader) *PostService {
return &PostService{
repo: repo,
ss: ss,
notifier: notifier,
userRepo: userRepo,
}
}
// WithHomePageInvalidator 注入首页缓存失效回调(文章变更时触发)
func (s *PostService) WithHomePageInvalidator(fn func()) {
s.onHomePageChanged = fn
}
// 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, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt 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,
Visibility: visibility,
PostType: postType,
ReprintSource: reprintSource,
Declaration: declaration,
ReprintProhibited: reprintProhibited,
}
// 解析分类 ID
if cid, err := strconv.ParseUint(categoryID, 10, 64); err == nil && cid > 0 {
id := uint(cid)
post.CategoryID = &id
}
// 定时发布
if t, err := parseScheduledAt(scheduledAt); err == nil {
if time.Until(t) < 30*time.Minute {
return nil, common.ErrScheduledTooSoon
}
post.ScheduledAt = &t
}
if err := s.repo.Create(post); err != nil {
return nil, err
}
s.invalidateHomeCache()
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"`
TotalViews int64 `json:"total_views"`
}
// GetOverview 获取创作中心数据概览(单次 GROUP BY 查询)
func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews, err := s.repo.GetOverviewByUserID(userID)
if err != nil {
return nil, err
}
return &StudioOverview{
TotalPosts: totalPosts,
DraftCount: draftCount,
PendingCount: pendingCount,
ApprovedCount: approvedCount,
RejectedCount: rejectedCount,
TotalViews: totalViews,
}, nil
}
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
}
// CanCreatePost 检查用户是否可以创建帖子LV0 用户不允许投稿)
func (s *PostService) CanCreatePost(userID uint) bool {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return false
}
return user.Exp >= 1
}
// invalidateHomeCache 失效首页缓存(异步安全调用)
func (s *PostService) invalidateHomeCache() {
if s.onHomePageChanged != nil {
s.onHomePageChanged()
}
}
// 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)
}
// Pin 置顶文章
func (s *PostService) Pin(postID uint, pinType string) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
now := time.Now()
post.PinType = pinType
post.PinnedAt = &now
return s.repo.Update(post)
}
// Unpin 取消置顶
func (s *PostService) Unpin(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
post.PinType = ""
post.PinnedAt = nil
return s.repo.Update(post)
}
// parseScheduledAt 解析 RFC3339 时间,非法格式返回零值+error
func parseScheduledAt(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, err
}
return t, nil
}