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

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

View File

@ -36,7 +36,7 @@ type auditStatusProvider interface {
FindByID(id uint) (*model.User, error)
}
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP7 个方法)
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP9 个方法)
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
}

View File

@ -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": "本文仅代表作者个人观点",
}

View File

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

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/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)
}

View File

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

View File

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