This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/internal/service/post_service.go
Victor_Jay 7659aee468 feat: 新增创作中心(/studio),支持数据概览、内容管理、草稿箱、写文章
- 新增 StudioController,依赖 studioUseCase 接口,遵循 ISP 接口隔离
- PostRepo 新增 FindByUserIDAndStatus 方法,按用户+状态分页查询
- PostService 新增 ListByUser、GetOverview 及 StudioOverview 统计
- /studio 页面路由(概览/管理/草稿箱/写文章/数据分析)+ /api/studio API
- 复用 Vditor 编辑器,studio-editor.js 对接 /api/studio/posts 端点
- 新增 studio.css(B站风格三栏布局+统计卡片+状态筛选tabs)
- 模板引擎注册 add/subtract 函数,分页模板中直接使用
2026-05-30 19:55:29 +08:00

318 lines
9.0 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
}
// 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.Status == model.PostStatusLocked {
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 锁定:任意状态 → locked管理员可随时锁定
func (s *PostService) Lock(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
post.Status = model.PostStatusLocked
return s.repo.Update(post)
}
// Unlock 解锁locked → 草稿
func (s *PostService) Unlock(postID uint) error {
post, err := s.findPost(postID)
if err != nil {
return err
}
if post.Status != model.PostStatusLocked {
return common.ErrPostCannotUnlock
}
post.Status = model.PostStatusDraft
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)
}