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:
2026-06-22 00:30:29 +08:00
parent 977c52513a
commit 0b8f6f890d
30 changed files with 988 additions and 13 deletions

View 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()
}

View File

@ -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 {

View File

@ -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
}

View 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, "")
}