feat: 分类系统 + 标签系统 — Phase 4 完成
分类:Model/Repo/Service/Controller + 管理后台树形页面 + 首页导航栏
标签:Model/Repo/Service/Controller + /tags/{slug} 落地页 + 写文章页输入
Post 加 CategoryID,Create/Update 支持分类和标签
SQL 迁移含 categories/tags/post_tags 表及预设未分类
This commit is contained in:
75
internal/service/category_service.go
Normal file
75
internal/service/category_service.go
Normal file
@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import "metazone.cc/mce/internal/model"
|
||||
|
||||
// CategoryStore 分类仓库接口(ISP)
|
||||
type CategoryStore interface {
|
||||
Create(c *model.Category) error
|
||||
Update(c *model.Category) error
|
||||
Delete(id uint) error
|
||||
FindByID(id uint) (*model.Category, error)
|
||||
ListAll() ([]model.Category, error)
|
||||
GetLeaves() ([]model.Category, error)
|
||||
}
|
||||
|
||||
// CategoryService 分类业务逻辑
|
||||
type CategoryService struct {
|
||||
repo CategoryStore
|
||||
}
|
||||
|
||||
// NewCategoryService 构造函数
|
||||
func NewCategoryService(repo CategoryStore) *CategoryService {
|
||||
return &CategoryService{repo: repo}
|
||||
}
|
||||
|
||||
// Create 创建分类
|
||||
func (s *CategoryService) Create(c *model.Category) error {
|
||||
return s.repo.Create(c)
|
||||
}
|
||||
|
||||
// Update 更新分类
|
||||
func (s *CategoryService) Update(c *model.Category) error {
|
||||
return s.repo.Update(c)
|
||||
}
|
||||
|
||||
// Delete 删除分类
|
||||
func (s *CategoryService) Delete(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// Tree 获取两级分类树
|
||||
func (s *CategoryService) Tree() ([]model.Category, error) {
|
||||
all, err := s.repo.ListAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 构建树:parent_id=nil 的为根节点
|
||||
parentMap := make(map[uint]*model.Category)
|
||||
var roots []*model.Category
|
||||
for i := range all {
|
||||
c := &all[i]
|
||||
c.Children = []model.Category{}
|
||||
parentMap[c.ID] = c
|
||||
if c.ParentID == nil {
|
||||
roots = append(roots, c)
|
||||
}
|
||||
}
|
||||
for i := range all {
|
||||
c := &all[i]
|
||||
if c.ParentID != nil {
|
||||
if parent, ok := parentMap[*c.ParentID]; ok {
|
||||
parent.Children = append(parent.Children, *c)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]model.Category, len(roots))
|
||||
for i, r := range roots {
|
||||
result[i] = *r
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLeaves 获取所有叶子分类(用于写文章页下拉选择)
|
||||
func (s *CategoryService) GetLeaves() ([]model.Category, error) {
|
||||
return s.repo.GetLeaves()
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
"metazone.cc/mce/internal/model"
|
||||
)
|
||||
|
||||
// Update 编辑帖子(权限在 controller 层检查)
|
||||
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool) error {
|
||||
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -22,6 +24,13 @@ func (s *PostService) Update(postID uint, title, body string, visibility, postTy
|
||||
post.ReprintSource = reprintSource
|
||||
post.Declaration = declaration
|
||||
post.ReprintProhibited = reprintProhibited
|
||||
// 分类
|
||||
if cid, err := strconv.ParseUint(categoryID, 10, 64); err == nil && cid > 0 {
|
||||
id := uint(cid)
|
||||
post.CategoryID = &id
|
||||
} else {
|
||||
post.CategoryID = nil
|
||||
}
|
||||
|
||||
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
|
||||
if post.Status == model.PostStatusApproved {
|
||||
|
||||
@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
@ -68,7 +69,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
|
||||
// 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) (*model.Post, error) {
|
||||
func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error) {
|
||||
status := model.PostStatusApproved
|
||||
if s.auditEnabled() {
|
||||
status = model.PostStatusDraft
|
||||
@ -87,6 +88,11 @@ func (s *PostService) Create(userID uint, title, body string, visibility, postTy
|
||||
Declaration: declaration,
|
||||
ReprintProhibited: reprintProhibited,
|
||||
}
|
||||
// 解析分类 ID
|
||||
if cid, err := strconv.ParseUint(categoryID, 10, 64); err == nil && cid > 0 {
|
||||
id := uint(cid)
|
||||
post.CategoryID = &id
|
||||
}
|
||||
if err := s.repo.Create(post); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
82
internal/service/tag_service.go
Normal file
82
internal/service/tag_service.go
Normal file
@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"metazone.cc/mce/internal/model"
|
||||
)
|
||||
|
||||
// TagStore 标签仓库接口(ISP)
|
||||
type TagStore interface {
|
||||
FindOrCreate(name, slug string) (*model.Tag, error)
|
||||
FindBySlug(slug string) (*model.Tag, error)
|
||||
SetPostTags(postID uint, tagIDs []uint) error
|
||||
GetPostTags(postID uint) ([]model.Tag, error)
|
||||
GetPostsByTag(tagID uint, offset, limit int) ([]model.Post, int64, error)
|
||||
}
|
||||
|
||||
var slugRegex = regexp.MustCompile(`[^a-z0-9\-]`)
|
||||
|
||||
// TagService 标签业务逻辑
|
||||
type TagService struct {
|
||||
repo TagStore
|
||||
}
|
||||
|
||||
// NewTagService 构造函数
|
||||
func NewTagService(repo TagStore) *TagService {
|
||||
return &TagService{repo: repo}
|
||||
}
|
||||
|
||||
// ParseAndSave 解析标签名列表(逗号分隔),查找或创建,返回 tagID 列表
|
||||
func (s *TagService) ParseAndSave(tagStr string) ([]uint, error) {
|
||||
if strings.TrimSpace(tagStr) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
names := strings.Split(tagStr, ",")
|
||||
var tagIDs []uint
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > 50 {
|
||||
continue
|
||||
}
|
||||
slug := slugify(name)
|
||||
if seen[slug] {
|
||||
continue
|
||||
}
|
||||
seen[slug] = true
|
||||
tag, err := s.repo.FindOrCreate(name, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
return tagIDs, nil
|
||||
}
|
||||
|
||||
// SetPostTags 解析标签字符串并关联到文章
|
||||
func (s *TagService) SetPostTags(postID uint, tagStr string) error {
|
||||
tagIDs, err := s.ParseAndSave(tagStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.SetPostTags(postID, tagIDs)
|
||||
}
|
||||
|
||||
// GetPostsByTag 按标签分页查询文章
|
||||
func (s *TagService) GetPostsByTag(slug string, page, pageSize int) ([]model.Post, int64, *model.Tag, error) {
|
||||
tag, err := s.repo.FindBySlug(slug)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
posts, total, err := s.repo.GetPostsByTag(tag.ID, offset, pageSize)
|
||||
return posts, total, tag, err
|
||||
}
|
||||
|
||||
func slugify(name string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
return slugRegex.ReplaceAllString(s, "")
|
||||
}
|
||||
Reference in New Issue
Block a user