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:
2026-06-22 00:04:55 +08:00
parent fc57ed17d4
commit 9c452afb40
8 changed files with 127 additions and 8 deletions

View 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;

View File

@ -103,6 +103,44 @@ func (ctrl *AdminPostController) Unlock(c *gin.Context) {
common.OkMessage(c, "已解锁") 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 恢复软删除 // Restore 恢复软删除
func (ctrl *AdminPostController) Restore(c *gin.Context) { func (ctrl *AdminPostController) Restore(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)

View File

@ -36,7 +36,7 @@ type auditStatusProvider interface {
FindByID(id uint) (*model.User, error) FindByID(id uint) (*model.User, error)
} }
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP7 个方法) // adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP9 个方法)
type adminPostUseCase interface { type adminPostUseCase interface {
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
ListPendingRevisions(keyword 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 Reject(postID uint, reason string) error
Lock(postID uint, reason string) error Lock(postID uint, reason string) error
Unlock(postID uint) error Unlock(postID uint) error
Pin(postID uint, pinType string) error
Unpin(postID uint) error
Restore(postID uint) error Restore(postID uint) error
} }

View File

@ -19,6 +19,16 @@ type Post struct {
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"` PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"` PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,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"` AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能乘10冗余计数 TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能乘10冗余计数
@ -44,3 +54,32 @@ const (
PostStatusRejected = "rejected" PostStatusRejected = "rejected"
PostStatusLocked = "locked" 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": "本文仅代表作者个人观点",
}

View File

@ -62,22 +62,24 @@ func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
return query return query
} }
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题) // FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题,过滤私密
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) { 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) return r.pageResults(query, offset, limit)
} }
// FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶) // FindAdminPageable 管理后台分页查询(全状态筛选,待审置顶,管理可见私密
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery(keyword, status) query := r.buildQuery(keyword, status)
return r.pageResultsAdmin(query, offset, limit) return r.pageResultsAdmin(query, offset, limit)
} }
// FindByUserID 分页查询某用户发布的帖子(仅 approved含作者用户名 // FindByUserID 分页查询某用户发布的帖子(仅 approved + 公开,含作者用户名)
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) { func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
query := r.buildQuery("", model.PostStatusApproved). 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) 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) 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) { func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { 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 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 Offset(offset).Limit(limit).Find(&posts).Error
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err

View File

@ -74,6 +74,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPostAPI.POST("/:id/reject", d.adminPostCtrl.Reject) adminPostAPI.POST("/:id/reject", d.adminPostCtrl.Reject)
adminPostAPI.POST("/:id/lock", d.adminPostCtrl.Lock) adminPostAPI.POST("/:id/lock", d.adminPostCtrl.Lock)
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock) 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) adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
} }

View File

@ -3,6 +3,7 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/config" "metazone.cc/mce/internal/config"
@ -189,3 +190,26 @@ func (s *PostService) RecordGuestRead(visitorID string, postID uint) {
} }
_ = s.repo.IncrementViewsCount(postID) _ = 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)
}

View File

@ -8,6 +8,7 @@ import (
"strings" "strings"
"metazone.cc/mce/internal/common" "metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
) )
// TemplateRoot 模板根目录配置 // TemplateRoot 模板根目录配置
@ -27,6 +28,9 @@ func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) {
"str": str, "str": str,
"assetV": common.AssetV, "assetV": common.AssetV,
"safeHTML": func(s string) template.HTML { return template.HTML(s) }, "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 { "substr": func(s string, start, length int) string {
runes := []rune(s) runes := []rune(s)
if start >= len(runes) { if start >= len(runes) {