diff --git a/docs/migrations/004_categories_tags.sql b/docs/migrations/004_categories_tags.sql new file mode 100644 index 0000000..ad93966 --- /dev/null +++ b/docs/migrations/004_categories_tags.sql @@ -0,0 +1,32 @@ +-- 分类表(两级树形) +CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(200) DEFAULT '', + parent_id INT REFERENCES categories(id) ON DELETE SET NULL, + sort INT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 预设「未分类」 +INSERT INTO categories (id, name, slug) VALUES (1, '未分类', 'uncategorized') ON CONFLICT DO NOTHING; + +-- 标签表 +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 文章-标签关联表 +CREATE TABLE IF NOT EXISTS post_tags ( + post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, tag_id) +); + +-- 文章加分类外键 +ALTER TABLE posts ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL; diff --git a/internal/controller/admin/category_controller.go b/internal/controller/admin/category_controller.go new file mode 100644 index 0000000..fdbc134 --- /dev/null +++ b/internal/controller/admin/category_controller.go @@ -0,0 +1,100 @@ +package admin + +import ( + "net/http" + "strconv" + + "metazone.cc/mce/internal/common" + "metazone.cc/mce/internal/model" + + "github.com/gin-gonic/gin" +) + +// CategoryController 分类管理控制器 +type CategoryController struct { + svc categoryUseCase +} + +type categoryUseCase interface { + Create(c *model.Category) error + Update(c *model.Category) error + Delete(id uint) error + Tree() ([]model.Category, error) +} + +// NewCategoryController 构造函数 +func NewCategoryController(svc categoryUseCase) *CategoryController { + return &CategoryController{svc: svc} +} + +// Page 分类管理页面 +func (cc *CategoryController) Page(c *gin.Context) { + tree, _ := cc.svc.Tree() + c.HTML(http.StatusOK, "admin/categories/index.html", common.BuildAdminPageData(c, gin.H{ + "Title": "分类管理", + "ExtraCSS": "/admin/static/css/categories.css", + "ExtraJS": "/admin/static/js/categories.js", + "Tree": tree, + })) +} + +// List 分类列表 API +func (cc *CategoryController) List(c *gin.Context) { + tree, err := cc.svc.Tree() + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + if tree == nil { + tree = []model.Category{} + } + common.Ok(c, tree) +} + +// Create 创建分类 +func (cc *CategoryController) Create(c *gin.Context) { + var req model.Category + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + if err := cc.svc.Create(&req); err != nil { + common.Error(c, http.StatusInternalServerError, "创建失败") + return + } + common.OkMessage(c, "分类已创建") +} + +// Update 更新分类 +func (cc *CategoryController) Update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的 ID") + return + } + var req model.Category + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + req.ID = uint(id) + if err := cc.svc.Update(&req); err != nil { + common.Error(c, http.StatusInternalServerError, "更新失败") + return + } + common.OkMessage(c, "分类已更新") +} + +// Delete 删除分类 +func (cc *CategoryController) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的 ID") + return + } + if err := cc.svc.Delete(uint(id)); err != nil { + common.Error(c, http.StatusInternalServerError, "删除失败") + return + } + common.OkMessage(c, "分类已删除") +} diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index fc055b2..e4f57cf 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -42,9 +42,9 @@ type postUseCase interface { // studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法) type studioUseCase interface { - Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool) (*model.Post, error) + Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error) GetByID(id uint) (*model.Post, error) - Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool) error + Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error Delete(postID uint) error SubmitForAudit(postID uint) error ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) diff --git a/internal/controller/studio_controller.go b/internal/controller/studio_controller.go index b0a1a80..690166a 100644 --- a/internal/controller/studio_controller.go +++ b/internal/controller/studio_controller.go @@ -1,6 +1,7 @@ package controller import ( + "metazone.cc/mce/internal/model" "metazone.cc/mce/internal/service" ) @@ -9,11 +10,24 @@ type trendsUseCase interface { GetTrends(userID uint, since string) (*service.TrendResult, error) } +// tagParser ISP:标签解析 +type tagParser interface { + ParseAndSave(tagStr string) ([]uint, error) + SetPostTags(postID uint, tagStr string) error +} + // StudioController 创作中心控制器(SSR 页面 + API) type StudioController struct { postService studioUseCase energySvc energyDeducter trendsSvc trendsUseCase + tagSvc tagParser + categorySvc categoryLoader +} + +// categoryLoader ISP:分类叶子节点查询 +type categoryLoader interface { + GetLeaves() ([]model.Category, error) } // NewStudioController 构造函数 @@ -32,3 +46,15 @@ func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioContro ctrl.trendsSvc = svc return ctrl } + +// WithTagService 链式注入标签服务 +func (ctrl *StudioController) WithTagService(svc tagParser) *StudioController { + ctrl.tagSvc = svc + return ctrl +} + +// WithCategoryService 链式注入分类服务 +func (ctrl *StudioController) WithCategoryService(svc categoryLoader) *StudioController { + ctrl.categorySvc = svc + return ctrl +} diff --git a/internal/controller/studio_post_api_controller.go b/internal/controller/studio_post_api_controller.go index 082ffa6..257648a 100644 --- a/internal/controller/studio_post_api_controller.go +++ b/internal/controller/studio_post_api_controller.go @@ -30,12 +30,17 @@ func (ctrl *StudioController) Create(c *gin.Context) { return } - post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited) + post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID) if err != nil { common.Error(c, http.StatusInternalServerError, "发布失败") return } + // 关联标签 + if ctrl.tagSvc != nil && req.Tags != "" { + _ = ctrl.tagSvc.SetPostTags(post.ID, req.Tags) + } + common.OkWithMessage(c, post, "发布成功") } @@ -69,10 +74,15 @@ func (ctrl *StudioController) Update(c *gin.Context) { return } - if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited); err != nil { + if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID); err != nil { common.Error(c, http.StatusInternalServerError, "编辑失败") return } + // 更新标签 + if ctrl.tagSvc != nil { + _ = ctrl.tagSvc.SetPostTags(uint(id), req.Tags) + } + common.OkMessage(c, "编辑成功") } diff --git a/internal/controller/studio_write_controller.go b/internal/controller/studio_write_controller.go index 6e4908e..0776491 100644 --- a/internal/controller/studio_write_controller.go +++ b/internal/controller/studio_write_controller.go @@ -17,6 +17,16 @@ func (ctrl *StudioController) WritePage(c *gin.Context) { return } + // 加载分类列表(叶子节点) + var categories []interface{} + if ctrl.categorySvc != nil { + if leaves, err := ctrl.categorySvc.GetLeaves(); err == nil { + for _, cat := range leaves { + categories = append(categories, cat) + } + } + } + // 支持编辑模式:传入 post_id 参数 postIDStr := c.Query("id") if postIDStr != "" { @@ -32,10 +42,11 @@ func (ctrl *StudioController) WritePage(c *gin.Context) { return } c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ - "Title": "编辑文章 - 创作中心", - "Post": post, - "ActiveTab": "write", - "ExtraCSS": "/static/css/studio.css", + "Title": "编辑文章 - 创作中心", + "Post": post, + "ActiveTab": "write", + "ExtraCSS": "/static/css/studio.css", + "Categories": categories, })) return } @@ -43,9 +54,10 @@ func (ctrl *StudioController) WritePage(c *gin.Context) { } c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ - "Title": "写文章 - 创作中心", - "ActiveTab": "write", - "ExtraCSS": "/static/css/studio.css", + "Title": "写文章 - 创作中心", + "ActiveTab": "write", + "ExtraCSS": "/static/css/studio.css", + "Categories": categories, })) } diff --git a/internal/controller/tag_controller.go b/internal/controller/tag_controller.go new file mode 100644 index 0000000..6eb5f73 --- /dev/null +++ b/internal/controller/tag_controller.go @@ -0,0 +1,54 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/mce/internal/common" + "metazone.cc/mce/internal/model" + + "github.com/gin-gonic/gin" +) + +// TagController 标签控制器(标签落地页) +type TagController struct { + svc tagUseCase +} + +type tagUseCase interface { + GetPostsByTag(slug string, page, pageSize int) ([]model.Post, int64, *model.Tag, error) +} + +// NewTagController 构造函数 +func NewTagController(svc tagUseCase) *TagController { + return &TagController{svc: svc} +} + +// TagPage 标签落地页 +func (tc *TagController) TagPage(c *gin.Context) { + slug := c.Param("slug") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize := 20 + + posts, total, tag, err := tc.svc.GetPostsByTag(slug, page, pageSize) + if err != nil { + c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ + "Title": "未找到", + "ErrorMsg": "该标签不存在", + })) + return + } + + totalPages := common.PageCount(total, pageSize) + c.HTML(http.StatusOK, "tags/detail.html", common.BuildPageData(c, gin.H{ + "Title": "标签:" + tag.Name, + "ExtraCSS": "/static/css/posts.css", + "Posts": posts, + "Total": total, + "Page": page, + "TotalPages": totalPages, + "PrevPage": max(1, page-1), + "NextPage": min(totalPages, page+1), + "Tag": tag, + })) +} diff --git a/internal/model/category.go b/internal/model/category.go new file mode 100644 index 0000000..a596ba2 --- /dev/null +++ b/internal/model/category.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// Category 文章分类(两级树形结构) +type Category struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"type:varchar(50);not null" json:"name"` + Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"` + Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"` + ParentID *uint `gorm:"index" json:"parent_id,omitempty"` + Sort int `gorm:"default:0" json:"sort"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 非 DB 字段(树形填充) + Children []Category `gorm:"-" json:"children,omitempty"` +} diff --git a/internal/model/dto.go b/internal/model/dto.go index bdc807d..28172f2 100644 --- a/internal/model/dto.go +++ b/internal/model/dto.go @@ -39,6 +39,8 @@ type PostCreateRequest struct { ReprintSource string `json:"reprint_source"` Declaration string `json:"declaration"` ReprintProhibited bool `json:"reprint_prohibited"` + CategoryID string `json:"category_id"` + Tags string `json:"tags"` } // PostUpdateRequest 编辑请求 @@ -50,6 +52,8 @@ type PostUpdateRequest struct { ReprintSource string `json:"reprint_source"` Declaration string `json:"declaration"` ReprintProhibited bool `json:"reprint_prohibited"` + CategoryID string `json:"category_id"` + Tags string `json:"tags"` } // PostRejectRequest 退回请求 diff --git a/internal/model/post.go b/internal/model/post.go index ccaa360..1e13de4 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -21,6 +21,7 @@ type Post struct { RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` // 文章属性 + CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源 diff --git a/internal/model/tag.go b/internal/model/tag.go new file mode 100644 index 0000000..a6d9706 --- /dev/null +++ b/internal/model/tag.go @@ -0,0 +1,17 @@ +package model + +import "time" + +// Tag 标签 +type Tag struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"type:varchar(50);not null" json:"name"` + Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"` + CreatedAt time.Time `json:"created_at"` +} + +// PostTag 文章-标签关联 +type PostTag struct { + PostID uint `gorm:"primarykey" json:"post_id"` + TagID uint `gorm:"primarykey" json:"tag_id"` +} diff --git a/internal/repository/category_repo.go b/internal/repository/category_repo.go new file mode 100644 index 0000000..36190f2 --- /dev/null +++ b/internal/repository/category_repo.go @@ -0,0 +1,63 @@ +package repository + +import ( + "metazone.cc/mce/internal/model" + "metazone.cc/mce/internal/service" + + "gorm.io/gorm" +) + +// CategoryRepo 分类数据访问 +type CategoryRepo struct { + db *gorm.DB +} + +// NewCategoryRepo 构造函数 +func NewCategoryRepo(db *gorm.DB) *CategoryRepo { + return &CategoryRepo{db: db} +} + +// WithTx 基于事务创建新实例 +func (r *CategoryRepo) WithTx(tx *gorm.DB) service.CategoryStore { + return &CategoryRepo{db: tx} +} + +// Create 创建分类 +func (r *CategoryRepo) Create(c *model.Category) error { + return r.db.Create(c).Error +} + +// Update 更新分类 +func (r *CategoryRepo) Update(c *model.Category) error { + return r.db.Save(c).Error +} + +// Delete 删除分类 +func (r *CategoryRepo) Delete(id uint) error { + return r.db.Delete(&model.Category{}, id).Error +} + +// FindByID 按 ID 查询 +func (r *CategoryRepo) FindByID(id uint) (*model.Category, error) { + var c model.Category + err := r.db.First(&c, id).Error + if err != nil { + return nil, err + } + return &c, nil +} + +// ListAll 查询全部分类(按 sort 排序) +func (r *CategoryRepo) ListAll() ([]model.Category, error) { + var list []model.Category + err := r.db.Order("sort ASC, id ASC").Find(&list).Error + return list, err +} + +// GetLeaves 查询所有叶子分类(有子分类的父节点不返回) +func (r *CategoryRepo) GetLeaves() ([]model.Category, error) { + var leaves []model.Category + err := r.db.Where("id NOT IN (SELECT DISTINCT parent_id FROM categories WHERE parent_id IS NOT NULL)"). + Order("sort ASC, id ASC").Find(&leaves).Error + return leaves, err +} diff --git a/internal/repository/tag_repo.go b/internal/repository/tag_repo.go new file mode 100644 index 0000000..829ccef --- /dev/null +++ b/internal/repository/tag_repo.go @@ -0,0 +1,97 @@ +package repository + +import ( + "metazone.cc/mce/internal/model" + "metazone.cc/mce/internal/service" + + "gorm.io/gorm" +) + +// TagRepo 标签数据访问 +type TagRepo struct { + db *gorm.DB +} + +// NewTagRepo 构造函数 +func NewTagRepo(db *gorm.DB) *TagRepo { + return &TagRepo{db: db} +} + +// WithTx 基于事务创建新实例 +func (r *TagRepo) WithTx(tx *gorm.DB) service.TagStore { + return &TagRepo{db: tx} +} + +// FindOrCreate 查找或创建标签(按名字) +func (r *TagRepo) FindOrCreate(name, slug string) (*model.Tag, error) { + var tag model.Tag + err := r.db.Where("slug = ?", slug).First(&tag).Error + if err == nil { + return &tag, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + tag = model.Tag{Name: name, Slug: slug} + if err := r.db.Create(&tag).Error; err != nil { + return nil, err + } + return &tag, nil +} + +// FindBySlug 按 slug 查询 +func (r *TagRepo) FindBySlug(slug string) (*model.Tag, error) { + var tag model.Tag + err := r.db.Where("slug = ?", slug).First(&tag).Error + if err != nil { + return nil, err + } + return &tag, nil +} + +// SetPostTags 设置文章的标签(删除旧的,插入新的) +func (r *TagRepo) SetPostTags(postID uint, tagIDs []uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("post_id = ?", postID).Delete(&model.PostTag{}).Error; err != nil { + return err + } + for _, tagID := range tagIDs { + if err := tx.Create(&model.PostTag{PostID: postID, TagID: tagID}).Error; err != nil { + return err + } + } + return nil + }) +} + +// GetPostTags 获取文章关联的标签 +func (r *TagRepo) GetPostTags(postID uint) ([]model.Tag, error) { + var tags []model.Tag + err := r.db.Table("tags"). + Joins("JOIN post_tags ON post_tags.tag_id = tags.id"). + Where("post_tags.post_id = ?", postID). + Find(&tags).Error + return tags, err +} + +// GetPostsByTag 按标签查询文章(公开+已发布) +func (r *TagRepo) GetPostsByTag(tagID uint, offset, limit int) ([]model.Post, int64, error) { + var total int64 + query := r.db.Table("posts"). + Joins("JOIN post_tags ON post_tags.post_id = posts.id"). + Joins("LEFT JOIN users ON users.id = posts.user_id"). + Where("post_tags.tag_id = ?", tagID). + Where("posts.status = ?", model.PostStatusApproved). + Where("posts.visibility = ?", model.VisibilityPublic). + Where("posts.deleted_at IS NULL"). + Select("posts.*, users.username as author_name") + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var posts []model.Post + err := query.Order("CASE WHEN posts.pin_type = 'global' THEN 0 ELSE 1 END ASC, posts.pinned_at DESC NULLS LAST, posts.created_at DESC"). + Offset(offset).Limit(limit).Find(&posts).Error + return posts, total, err +} diff --git a/internal/router/admin.go b/internal/router/admin.go index e4a9231..057c3b4 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -33,6 +33,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage) adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage) adminPages.GET("/announcements", d.announcementCtrl.Page) + adminPages.GET("/categories", d.categoryCtrl.Page) } // --- 管理后台 API --- @@ -115,4 +116,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { announcementAPI.PUT("/:id", d.announcementCtrl.Update) announcementAPI.DELETE("/:id", d.announcementCtrl.Delete) } + + // --- 分类管理 API(moderator+) --- + categoryAPI := r.Group("/api/admin/categories") + categoryAPI.Use(d.authMdw.Required()) + categoryAPI.Use(middleware.RequireMinRole(model.RoleModerator)) + categoryAPI.Use(d.authMdw.BannedWriteGuard()) + categoryAPI.Use(middleware.CSRF(cfg)) + { + categoryAPI.GET("", d.categoryCtrl.List) + categoryAPI.POST("", d.categoryCtrl.Create) + categoryAPI.PUT("/:id", d.categoryCtrl.Update) + categoryAPI.DELETE("/:id", d.categoryCtrl.Delete) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index c067533..a573c0d 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -47,6 +47,10 @@ type dependencies struct { homeCache *cache.HomePageCache announcementService *service.AnnouncementService announcementCtrl *adminCtrl.AnnouncementController + categoryCtrl *adminCtrl.CategoryController + categoryService *service.CategoryService + tagCtrl *controller.TagController + tagService *service.TagService } func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 73f5287..3a184b4 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -103,6 +103,18 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr) + // 分类系统 + categoryRepo := repository.NewCategoryRepo(db) + categoryService := service.NewCategoryService(categoryRepo) + categoryCtrl := adminCtrl.NewCategoryController(categoryService) + studioCtrl.WithCategoryService(categoryService) + + // 标签系统 + tagRepo := repository.NewTagRepo(db) + tagService := service.NewTagService(tagRepo) + tagCtrl := controller.NewTagController(tagService) + studioCtrl.WithTagService(tagService) + // 公告系统 announcementRepo := repository.NewAnnouncementRepo(db) announcementService := service.NewAnnouncementService(announcementRepo) @@ -135,5 +147,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting homeCache: homeCache, announcementService: announcementService, announcementCtrl: announcementCtrl, + categoryCtrl: categoryCtrl, + categoryService: categoryService, + tagCtrl: tagCtrl, + tagService: tagService, } } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index aaf7079..3065b20 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -34,12 +34,14 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { } } announcements, _ := d.announcementService.ListActive() + categoryNav, _ := d.categoryService.GetLeaves() c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{ "Title": "首页", "ExtraCSS": "/static/css/home.css", "Posts": posts, "PostCount": total, "Announcements": announcements, + "CategoryNav": categoryNav, })) }) @@ -56,6 +58,9 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { // 帖子页面 pages.GET("/posts", d.postCtrl.ListPage) pages.GET("/posts/:id", d.postCtrl.ShowPage) + + // 标签落地页 + pages.GET("/tags/:slug", d.tagCtrl.TagPage) pages.GET("/posts/:id/edit", d.authMdw.Required(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id")) }) diff --git a/internal/service/category_service.go b/internal/service/category_service.go new file mode 100644 index 0000000..6a841cc --- /dev/null +++ b/internal/service/category_service.go @@ -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() +} diff --git a/internal/service/post_audit_service.go b/internal/service/post_audit_service.go index 8d81f23..4ca9b8f 100644 --- a/internal/service/post_audit_service.go +++ b/internal/service/post_audit_service.go @@ -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 { diff --git a/internal/service/post_service.go b/internal/service/post_service.go index ee753f4..24af317 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -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 } diff --git a/internal/service/tag_service.go b/internal/service/tag_service.go new file mode 100644 index 0000000..aceccdb --- /dev/null +++ b/internal/service/tag_service.go @@ -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, "") +} diff --git a/templates/MetaLab-2026/html/home/index.html b/templates/MetaLab-2026/html/home/index.html index 05826a9..483d0c5 100644 --- a/templates/MetaLab-2026/html/home/index.html +++ b/templates/MetaLab-2026/html/home/index.html @@ -29,6 +29,16 @@ {{end}} +{{if .CategoryNav}} + +
+{{end}} +