- Post 模型新增 IsLocked 字段,与 Status 解耦 - Lock 仅设 IsLocked=true(不改 Status),已通过帖子锁定后仍公开可见 - Unlock 仅设 IsLocked=false(不改 Status) - Update/EditPage 编辑检查改用 IsLocked 而非 Status==locked - Reject 与 Lock 独立,可单独退回或锁定+退回组合使用 - 新增 ErrPostCannotLock 错误哨兵 - 前端模板编辑按钮/锁定标识基于 IsLocked 渲染
323 lines
9.2 KiB
Go
323 lines
9.2 KiB
Go
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("`[^`]+`"),
|
||
// 图片 
|
||
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
|
||
}
|
||
|
||
// NewPostService 构造函数
|
||
func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
|
||
return &PostService{
|
||
repo: repo,
|
||
ss: ss,
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
post.Title = title
|
||
post.Body = body
|
||
post.Excerpt = generateExcerpt(body)
|
||
|
||
// rejected 状态编辑后自动重置为 draft
|
||
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.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
|
||
return common.ErrPostCannotSubmit
|
||
}
|
||
post.Status = model.PostStatusPending
|
||
return s.repo.Update(post)
|
||
}
|
||
|
||
// Approve 审核通过:pending → approved
|
||
func (s *PostService) Approve(postID uint) error {
|
||
post, err := s.findPost(postID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if post.Status != model.PostStatusPending {
|
||
return common.ErrPostCannotApprove
|
||
}
|
||
post.Status = model.PostStatusApproved
|
||
post.RejectReason = ""
|
||
return s.repo.Update(post)
|
||
}
|
||
|
||
// Reject 退回:pending/approved → rejected
|
||
// 独立于锁定:可单独退回(允许作者修改后重审),也可锁定+退回(禁止再投稿)
|
||
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
|
||
}
|
||
post.Status = model.PostStatusRejected
|
||
post.RejectReason = reason
|
||
return s.repo.Update(post)
|
||
}
|
||
|
||
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
||
func (s *PostService) Lock(postID uint) error {
|
||
post, err := s.findPost(postID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 待审核帖子不允许锁定(审核流程中)
|
||
if post.Status == model.PostStatusPending {
|
||
return common.ErrPostCannotLock
|
||
}
|
||
post.IsLocked = true
|
||
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
|
||
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
|
||
}
|
||
|
||
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+)
|
||
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
||
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
||
}
|