From 9c452afb406a9191dc0f8f240810cff18d0fdaef Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Mon, 22 Jun 2026 00:04:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E7=AB=A0=E5=B1=9E=E6=80=A7=20?= =?UTF-8?q?=E2=80=94=20Post=20Model=20=E6=89=A9=E5=B1=95=20+=20=E7=BD=AE?= =?UTF-8?q?=E9=A1=B6=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Post 新增 7 字段:Visibility/PostType/ReprintSource/Declaration/ReprintProhibited/PinType/PinnedAt - 公开列表过滤 private 文章(管理员可见全部) - 列表排序:全局置顶 > 按时间 - declarationLabel 模板函数(7 种创作声明) - Pin/Unpin API(admin+,category/global 两种置顶类型) --- docs/migrations/003_post_attributes.sql | 8 ++++ .../admin/admin_post_action_controller.go | 38 ++++++++++++++++++ internal/controller/admin/interfaces.go | 4 +- internal/model/post.go | 39 +++++++++++++++++++ internal/repository/post_repo.go | 16 ++++---- internal/router/admin.go | 2 + internal/service/post_service.go | 24 ++++++++++++ internal/theme/loader.go | 4 ++ 8 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 docs/migrations/003_post_attributes.sql diff --git a/docs/migrations/003_post_attributes.sql b/docs/migrations/003_post_attributes.sql new file mode 100644 index 0000000..b22791a --- /dev/null +++ b/docs/migrations/003_post_attributes.sql @@ -0,0 +1,8 @@ +-- 文章属性扩展 +ALTER TABLE posts ADD COLUMN IF NOT EXISTS visibility VARCHAR(10) DEFAULT 'public'; +ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type VARCHAR(10) DEFAULT 'original'; +ALTER TABLE posts ADD COLUMN IF NOT EXISTS reprint_source VARCHAR(500) DEFAULT ''; +ALTER TABLE posts ADD COLUMN IF NOT EXISTS declaration VARCHAR(30) DEFAULT ''; +ALTER TABLE posts ADD COLUMN IF NOT EXISTS reprint_prohibited BOOLEAN DEFAULT false; +ALTER TABLE posts ADD COLUMN IF NOT EXISTS pin_type VARCHAR(10) DEFAULT ''; +ALTER TABLE posts ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP; diff --git a/internal/controller/admin/admin_post_action_controller.go b/internal/controller/admin/admin_post_action_controller.go index a4f187b..a8c0065 100644 --- a/internal/controller/admin/admin_post_action_controller.go +++ b/internal/controller/admin/admin_post_action_controller.go @@ -103,6 +103,44 @@ func (ctrl *AdminPostController) Unlock(c *gin.Context) { common.OkMessage(c, "已解锁") } +// Pin 置顶文章 +func (ctrl *AdminPostController) Pin(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + var req struct { + PinType string `json:"pin_type" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || (req.PinType != model.PinTypeCategory && req.PinType != model.PinTypeGlobal) { + common.Error(c, http.StatusBadRequest, "无效的置顶类型") + return + } + + if err := ctrl.postService.Pin(uint(id), req.PinType); err != nil { + common.Error(c, http.StatusInternalServerError, "置顶失败") + return + } + common.OkMessage(c, "已置顶") +} + +// Unpin 取消置顶 +func (ctrl *AdminPostController) Unpin(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的帖子 ID") + return + } + + if err := ctrl.postService.Unpin(uint(id)); err != nil { + common.Error(c, http.StatusInternalServerError, "取消置顶失败") + return + } + common.OkMessage(c, "已取消置顶") +} + // Restore 恢复软删除 func (ctrl *AdminPostController) Restore(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go index 79713bf..f610c6d 100644 --- a/internal/controller/admin/interfaces.go +++ b/internal/controller/admin/interfaces.go @@ -36,7 +36,7 @@ type auditStatusProvider interface { FindByID(id uint) (*model.User, error) } -// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:7 个方法) +// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:9 个方法) type adminPostUseCase interface { ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) @@ -44,6 +44,8 @@ type adminPostUseCase interface { Reject(postID uint, reason string) error Lock(postID uint, reason string) error Unlock(postID uint) error + Pin(postID uint, pinType string) error + Unpin(postID uint) error Restore(postID uint) error } diff --git a/internal/model/post.go b/internal/model/post.go index 307accf..ccaa360 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -19,6 +19,16 @@ type Post struct { PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"` PendingBody string `gorm:"type:text" json:"pending_body,omitempty"` RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"` + + // 文章属性 + 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"` // 转载来源 + Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明 + ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载 + PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global + PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间 + AllowComment bool `gorm:"default:true" json:"allow_comment"` CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数) @@ -44,3 +54,32 @@ const ( PostStatusRejected = "rejected" PostStatusLocked = "locked" ) + +// 可见性常量 +const ( + VisibilityPublic = "public" + VisibilityPrivate = "private" +) + +// 文章类型常量 +const ( + PostTypeOriginal = "original" + PostTypeReprint = "reprint" +) + +// 置顶类型常量 +const ( + PinTypeCategory = "category" + PinTypeGlobal = "global" +) + +// DeclarationLabels 创作声明名称映射 +var DeclarationLabels = map[string]string{ + "ai_generated": "该内容使用AI辅助创作", + "fictional": "该内容包含虚构情节", + "speculative": "该内容包含猜测/推测性内容", + "disturbing": "该内容可能包含令人不适的内容", + "spoiler": "该内容包含剧透内容", + "controversial": "该内容可能涉及争议性话题", + "opinion": "本文仅代表作者个人观点", +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 7c5039e..e9c170a 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -62,22 +62,24 @@ func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB { return query } -// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题) +// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题,过滤私密) func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) { - query := r.buildQuery(keyword, model.PostStatusApproved) + query := r.buildQuery(keyword, model.PostStatusApproved). + Where("posts.visibility = ?", model.VisibilityPublic) return r.pageResults(query, offset, limit) } -// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶) +// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密) func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery(keyword, status) return r.pageResultsAdmin(query, offset, limit) } -// FindByUserID 分页查询某用户发布的帖子(仅 approved,含作者用户名) +// FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开,含作者用户名) func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) { query := r.buildQuery("", model.PostStatusApproved). - Where("posts.user_id = ?", userID) + Where("posts.user_id = ?", userID). + Where("posts.visibility = ?", model.VisibilityPublic) return r.pageResults(query, offset, limit) } @@ -95,7 +97,7 @@ func (r *PostRepo) FindPendingRevisions(keyword string, offset, limit int) ([]mo return r.pageResults(query, offset, limit) } -// pageResults 执行 Count + Offset/Limit + ORDER BY +// pageResults 执行 Count + Offset/Limit + ORDER BY(置顶优先,再按时间排序) func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) { var total int64 if err := query.Count(&total).Error; err != nil { @@ -103,7 +105,7 @@ func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, } var posts []model.Post - err := query.Order("posts.created_at DESC"). + 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 if err != nil { return nil, 0, err diff --git a/internal/router/admin.go b/internal/router/admin.go index c5c093a..e4a9231 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -74,6 +74,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { adminPostAPI.POST("/:id/reject", d.adminPostCtrl.Reject) adminPostAPI.POST("/:id/lock", d.adminPostCtrl.Lock) adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock) + adminPostAPI.POST("/:id/pin", d.adminPostCtrl.Pin) + adminPostAPI.POST("/:id/unpin", d.adminPostCtrl.Unpin) adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore) } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 3c8fc93..94a0b99 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "time" "metazone.cc/mce/internal/common" "metazone.cc/mce/internal/config" @@ -189,3 +190,26 @@ func (s *PostService) RecordGuestRead(visitorID string, postID uint) { } _ = s.repo.IncrementViewsCount(postID) } + +// Pin 置顶文章 +func (s *PostService) Pin(postID uint, pinType string) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + now := time.Now() + post.PinType = pinType + post.PinnedAt = &now + return s.repo.Update(post) +} + +// Unpin 取消置顶 +func (s *PostService) Unpin(postID uint) error { + post, err := s.findPost(postID) + if err != nil { + return err + } + post.PinType = "" + post.PinnedAt = nil + return s.repo.Update(post) +} diff --git a/internal/theme/loader.go b/internal/theme/loader.go index 8e9a8b6..e6a563f 100644 --- a/internal/theme/loader.go +++ b/internal/theme/loader.go @@ -8,6 +8,7 @@ import ( "strings" "metazone.cc/mce/internal/common" + "metazone.cc/mce/internal/model" ) // TemplateRoot 模板根目录配置 @@ -27,6 +28,9 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) { "str": str, "assetV": common.AssetV, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, + "declarationLabel": func(declaration string) string { + return model.DeclarationLabels[declaration] + }, "substr": func(s string, start, length int) string { runes := []rune(s) if start >= len(runes) {