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, "已解锁")
|
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)
|
||||||
|
|||||||
@ -36,7 +36,7 @@ type auditStatusProvider interface {
|
|||||||
FindByID(id uint) (*model.User, error)
|
FindByID(id uint) (*model.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:7 个方法)
|
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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": "本文仅代表作者个人观点",
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user