feat: 文章属性 — Post Model 扩展 + 置顶系统
- Post 新增 7 字段:Visibility/PostType/ReprintSource/Declaration/ReprintProhibited/PinType/PinnedAt - 公开列表过滤 private 文章(管理员可见全部) - 列表排序:全局置顶 > 按时间 - declarationLabel 模板函数(7 种创作声明) - Pin/Unpin API(admin+,category/global 两种置顶类型)
This commit is contained in:
8
docs/migrations/003_post_attributes.sql
Normal file
8
docs/migrations/003_post_attributes.sql
Normal file
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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": "本文仅代表作者个人观点",
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user