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 69ba2fb01b refactor: ByteMD 迁移至 Tiptap WYSIWYG 编辑器 + Token 刷新优化
- 移除 ByteMD CDN 资源和 goldmark 依赖,替换为 Tiptap ESM 模块
- 编辑器从源码编辑模式改为 WYSIWYG 所见即所得
- 移除 /api/posts/preview 预览接口(Tiptap 直接输出 HTML 无需后端渲染)
- 保留 UploadImage 图片上传接口和 bluemonday HTML 消毒
- 工具栏支持加粗、斜体、删除线、代码、标题、列表、引用、代码块、分割线、撤销/重做
- 支持图片粘贴/拖拽上传,Ctrl+S 快捷提交,F11 全屏,字数统计
- 优化 common.js:自动 401 拦截 Token 刷新,修复刷新死循环
- 草稿 localStorage 键名更新为 draft_post_body_html
2026-05-28 11:21:53 +08:00

204 lines
5.7 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 (
"fmt"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"github.com/microcosm-cc/bluemonday"
)
// sanitizePolicy Tiptap 输出 HTML 的消毒策略
// 前端 WYSIWYG 编辑器输出 HTML由 bluemonday UGC 策略消毒
// 显式放行 code/pre 的 class 属性以保留代码块样式
// 放行 img 的 src/alt 属性以保留图片
var sanitizePolicy = func() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
p.AllowAttrs("class").OnElements("code", "pre")
p.AllowAttrs("src", "alt", "title").OnElements("img")
return p
}()
// postStore 帖子服务所需的最小仓储接口ISP
type postStore interface {
Create(post *model.Post) error
FindByID(id uint) (*model.Post, error)
FindByIDWithAuthor(id uint) (*model.Post, error)
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error
SoftDelete(id uint) error
Restore(id uint) error
}
// 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()
}
// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒
// 前端 WYSIWYG 编辑器直接输出 HTML后端仅做安全消毒
func (s *PostService) sanitizeHTML(body string) string {
return sanitizePolicy.Sanitize(body)
}
// Create 创建帖子
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
bodyHTML := s.sanitizeHTML(body)
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
}
post := &model.Post{
Title: title,
Body: body,
BodyHTML: bodyHTML,
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) {
post, err := s.repo.FindByIDWithAuthor(id)
if err != nil {
return nil, common.ErrUserNotFound // 复用哨兵表示资源不存在
}
return post, nil
}
// List 公开帖子列表(仅 approved
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
}
// ListAdmin 管理后台帖子列表(全状态)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
}
// Update 编辑帖子(权限在 controller 层检查)
func (s *PostService) Update(postID uint, title, body string) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
return fmt.Errorf("当前状态不允许编辑")
}
post.Title = title
post.Body = body
post.BodyHTML = s.sanitizeHTML(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.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
return fmt.Errorf("仅草稿和已退回帖子可提交审核")
}
post.Status = model.PostStatusPending
return s.repo.Update(post)
}
// Approve 审核通过pending → approved
func (s *PostService) Approve(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusPending {
return fmt.Errorf("仅待审核帖子可通过")
}
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.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
return fmt.Errorf("仅待审核和已发布帖子可退回")
}
post.Status = model.PostStatusRejected
post.RejectReason = reason
return s.repo.Update(post)
}
// Lock 锁定:任意状态 → locked
func (s *PostService) Lock(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
post.Status = model.PostStatusLocked
return s.repo.Update(post)
}
// Unlock 解锁locked → 草稿
func (s *PostService) Unlock(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusLocked {
return fmt.Errorf("仅锁定状态可解锁")
}
post.Status = model.PostStatusDraft
return s.repo.Update(post)
}
// Restore 恢复软删除
func (s *PostService) Restore(postID uint) error {
return s.repo.Restore(postID)
}