- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐 - 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调 - CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403 - Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议 - 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问 - 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表 - 导航栏:用户名链接指向 /space,新增「设置」入口 - 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
281 lines
7.6 KiB
Go
281 lines
7.6 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)
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
}
|