feat: 实现内容系统 MVC(Post CRUD + 审核流转)

按 content-system.md 设计文档,实现帖子内容系统的最小可行 MVC。

新增:
- Model: Post 模型,含 5 种状态(draft/pending/approved/rejected/locked)
- Repository: post_repo.go,分页查询(前台 approved 列表 + 后台全状态筛选)
- Service: post_service.go,核心业务逻辑(状态流转、Markdown 渲染 Goldmark)
- Controller: post_controller + admin_post_controller,SSR 页面 6 个 + API 13 个
- Template: 列表/详情/编辑器/404 + 管理员列表,各配 CSS/JS
- 路由: /posts, /posts/:id, /posts/new, /posts/:id/edit, REST API, Admin API
- gomod: + github.com/yuin/goldmark v1.8.2

修改:
- Main: AutoMigrate Post 表
- Router: deps 注册 PostService/Controller,路由注册
- Nav: 新增“帖子”导航链接
- Admin Sidebar: 新增“内容管理”入口

设计:
- 审核开关复用 audit.enabled,开启时帖子为 draft,关闭后直接 approved
- 仅 approved 公开可见,作者/admin 可预览非公开状态帖子
- 严格分层 ISP 接口: Controller → postUseCase, Service → postStore, Repo → *PostRepo
- 状态保护: pending/locked 禁止编辑,rejected 编辑后自动重置为 draft
This commit is contained in:
2026-05-27 18:22:48 +08:00
parent fc65872882
commit 03a01a09be
26 changed files with 1803 additions and 3 deletions

View File

@ -0,0 +1,149 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// AdminPostController 管理后台帖子控制器
type AdminPostController struct {
postService adminPostUseCase
}
// NewAdminPostController 构造函数
func NewAdminPostController(ps adminPostUseCase) *AdminPostController {
return &AdminPostController{postService: ps}
}
// PostsPage 管理后台帖子列表页
func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
keyword := c.Query("keyword")
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.ListAdmin(keyword, status, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "内容管理",
"Error": "加载帖子列表失败",
}))
return
}
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
prevPage := p.Page - 1
if prevPage < 1 { prevPage = 1 }
nextPage := p.Page + 1
if nextPage > totalPages { nextPage = totalPages }
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "内容管理",
"Posts": posts,
"Total": total,
"Page": p.Page,
"TotalPages": totalPages,
"PrevPage": prevPage,
"NextPage": nextPage,
"Keyword": keyword,
"Status": status,
"ExtraCSS": "/admin/static/css/posts.css",
}))
}
// Approve 审核通过
func (ctrl *AdminPostController) Approve(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.Approve(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "审核通过")
}
// Reject 退回
func (ctrl *AdminPostController) Reject(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostRejectRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请填写退回理由")
return
}
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "已退回")
}
// Lock 锁定
func (ctrl *AdminPostController) Lock(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.Lock(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "已锁定")
}
// Unlock 解锁
func (ctrl *AdminPostController) Unlock(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.Unlock(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "已解锁")
}
// Restore 恢复软删除
func (ctrl *AdminPostController) Restore(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.Restore(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "已恢复")
}

View File

@ -32,3 +32,13 @@ type siteSettingUseCase interface {
type auditStatusProvider interface {
FindByID(id uint) (*model.User, error)
}
// adminPostUseCase AdminPostController 对 PostService 的最小依赖ISP6 个方法)
type adminPostUseCase interface {
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
Approve(postID uint) error
Reject(postID uint, reason string) error
Lock(postID uint) error
Unlock(postID uint) error
Restore(postID uint) error
}

View File

@ -24,3 +24,14 @@ type rateLimiter interface {
AllowIP(ip string) (middleware.RateLimitResult, func())
Clear(email, ip string)
}
// postUseCase PostController 对 PostService 的最小依赖ISP8 个方法)
type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
List(keyword string, page, pageSize int) ([]model.Post, int64, error)
Update(postID uint, title, body string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
RenderMarkdown(body string) (string, error)
}

View File

@ -0,0 +1,319 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// PostController 帖子控制器SSR 页面 + API
type PostController struct {
postService postUseCase
}
// NewPostController 构造函数
func NewPostController(ps postUseCase) *PostController {
return &PostController{postService: ps}
}
// ListPage 帖子列表页
func (ctrl *PostController) ListPage(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Error": "加载帖子列表失败",
}))
return
}
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
prevPage := p.Page - 1
if prevPage < 1 { prevPage = 1 }
nextPage := p.Page + 1
if nextPage > totalPages { nextPage = totalPages }
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子",
"Posts": posts,
"Total": total,
"Page": p.Page,
"TotalPages": totalPages,
"PrevPage": prevPage,
"NextPage": nextPage,
"Keyword": keyword,
"ExtraCSS": "/static/css/posts.css",
}))
}
// ShowPage 帖子详情页
func (ctrl *PostController) ShowPage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
// 获取当前用户信息
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
if post.Status != model.PostStatusApproved {
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
}
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
"Title": post.Title,
"Post": post,
"UID": uid,
"ExtraCSS": "/static/css/posts.css",
}))
}
// NewPage 发帖页面
func (ctrl *PostController) NewPage(c *gin.Context) {
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "撰写帖子",
"ExtraCSS": "/static/css/posts.css",
"ExtraJS": "/static/js/editor.js",
}))
}
// EditPage 编辑页面
func (ctrl *PostController) EditPage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil || post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到或无法编辑",
}))
return
}
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "编辑帖子",
"Post": post,
"ExtraCSS": "/static/css/posts.css",
"ExtraJS": "/static/js/editor.js",
}))
}
// Create API 发帖
func (ctrl *PostController) Create(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req model.PostCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
if err != nil {
common.Error(c, http.StatusInternalServerError, "发帖失败")
return
}
common.OkWithMessage(c, post, "发布成功")
}
// Update API 编辑帖子
func (ctrl *PostController) Update(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, _ := uidObj.(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
// 权限检查:取帖子归属
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "编辑成功")
}
// Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, _ := uidObj.(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权删除此帖子")
return
}
if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "删除成功")
}
// Submit API 提交审核
func (ctrl *PostController) Submit(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.SubmitForAudit(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "已提交审核")
}
// ListAPI 帖子列表 JSON API
func (ctrl *PostController) ListAPI(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
return
}
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
common.Ok(c, model.PostListResult{
Items: posts,
Total: total,
Page: p.Page,
TotalPages: totalPages,
})
}
// ShowAPI 帖子详情 JSON API
func (ctrl *PostController) ShowAPI(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
common.Ok(c, post)
}
// PreviewAPI Markdown 预览 API
func (ctrl *PostController) PreviewAPI(c *gin.Context) {
var req struct {
Body string `json:"body" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "正文不能为空")
return
}
html, err := ctrl.postService.RenderMarkdown(req.Body)
if err != nil {
common.Error(c, http.StatusInternalServerError, "渲染失败")
return
}
common.Ok(c, gin.H{"html": html})
}

76
internal/model/post.go Normal file
View File

@ -0,0 +1,76 @@
package model
import (
"time"
"gorm.io/gorm"
)
// Post 帖子/文章模型
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"`
BodyHTML string `gorm:"type:text" json:"body_html"`
UserID uint `gorm:"index;not null" json:"user_id"`
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
AllowComment bool `gorm:"default:true" json:"allow_comment"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充)
AuthorName string `gorm:"-:all" json:"author_name,omitempty"`
}
// 帖子状态常量
const (
PostStatusDraft = "draft"
PostStatusPending = "pending"
PostStatusApproved = "approved"
PostStatusRejected = "rejected"
PostStatusLocked = "locked"
)
// PostStatusDisplayNames 状态 → 中文名
var PostStatusDisplayNames = map[string]string{
PostStatusDraft: "草稿",
PostStatusPending: "待审核",
PostStatusApproved: "已发布",
PostStatusRejected: "已退回",
PostStatusLocked: "已锁定",
}
// PostListResult 帖子列表查询结果
type PostListResult struct {
Items []Post `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
// PostCreateRequest 发帖请求
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
}
// PostRejectRequest 退回请求
type PostRejectRequest struct {
Reason string `json:"reason" binding:"required,min=1,max=500"`
}
// PostListQuery 列表查询参数
type PostListQuery struct {
Keyword string `form:"keyword"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}

View File

@ -0,0 +1,117 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// PostRepo 帖子数据访问
type PostRepo struct {
db *gorm.DB
}
// NewPostRepo 构造函数
func NewPostRepo(db *gorm.DB) *PostRepo {
return &PostRepo{db: db}
}
// Create 创建帖子
func (r *PostRepo) Create(post *model.Post) error {
return r.db.Create(post).Error
}
// FindByID 按 ID 查找帖子
func (r *PostRepo) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := r.db.First(&post, id).Error
if err != nil {
return nil, err
}
return &post, nil
}
// FindByIDWithAuthor 按 ID 查找帖子(含作者用户名)
func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
var post model.Post
err := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.id = ?", id).
First(&post).Error
if err != nil {
return nil, err
}
return &post, nil
}
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题)
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL").
Where("posts.status = ?", model.PostStatusApproved)
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("posts.title LIKE ?", like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
err := query.Order("posts.created_at DESC").
Offset(offset).Limit(limit).Find(&posts).Error
if err != nil {
return nil, 0, err
}
return posts, total, nil
}
// FindAdminPageable 管理后台分页查询(全状态筛选)
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
query := r.db.Table("posts").
Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id").
Where("posts.deleted_at IS NULL")
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("posts.title LIKE ?", like)
}
if status != "" {
query = query.Where("posts.status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
err := query.Order("posts.created_at DESC").
Offset(offset).Limit(limit).Find(&posts).Error
if err != nil {
return nil, 0, err
}
return posts, total, nil
}
// Update 更新帖子
func (r *PostRepo) Update(post *model.Post) error {
return r.db.Save(post).Error
}
// SoftDelete 软删除帖子
func (r *PostRepo) SoftDelete(id uint) error {
return r.db.Delete(&model.Post{}, id).Error
}
// Restore 恢复软删除
func (r *PostRepo) Restore(id uint) error {
return r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
}

View File

@ -25,6 +25,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
middleware.RequirePageRole(model.RoleAdmin))
adminPages.GET("/audits", d.auditCtrl.AuditPage,
middleware.RequirePageRole(model.RoleAdmin))
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
middleware.RequirePageRole(model.RoleOwner))
}
@ -54,4 +55,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
}
// --- 帖子管理 APIadmin+ ---
adminPostAPI := r.Group("/api/admin/posts")
adminPostAPI.Use(d.authMdw.Required())
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
adminPostAPI.Use(middleware.CSRF(cfg))
{
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)
adminPostAPI.POST("/:id/reject", d.adminPostCtrl.Reject)
adminPostAPI.POST("/:id/lock", d.adminPostCtrl.Lock)
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock)
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
}
}

View File

@ -41,5 +41,21 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
api.POST("/auth/confirm-restore", d.tokenCtrl.ConfirmRestore)
api.POST("/auth/logout", d.tokenCtrl.Logout)
api.POST("/auth/refresh", d.tokenCtrl.RefreshToken)
// 帖子公开 API无需登录
api.GET("/posts", d.postCtrl.ListAPI)
api.GET("/posts/:id", d.postCtrl.ShowAPI)
}
// --- 帖子 API需登录 ---
postsAPI := r.Group("/api/posts")
postsAPI.Use(d.authMdw.Required())
postsAPI.Use(middleware.CSRF(cfg))
{
postsAPI.POST("", d.postCtrl.Create)
postsAPI.PUT("/:id", d.postCtrl.Update)
postsAPI.DELETE("/:id", d.postCtrl.Delete)
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
postsAPI.POST("/preview", d.postCtrl.PreviewAPI)
}
}

View File

@ -16,6 +16,8 @@ type dependencies struct {
authMdw *middleware.AuthMiddleware
settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController
postCtrl *controller.PostController
adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController
siteSettingCtrl *adminCtrl.SiteSettingController

View File

@ -30,9 +30,15 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
},
)
postRepo := repository.NewPostRepo(db)
postService := service.NewPostService(postRepo, siteSettings)
postCtrl := controller.NewPostController(postService)
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
return &dependencies{
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
messageCtrl: messageController, adminCtrl: adminController,
auditCtrl: auditController, siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl,
adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
}
}

View File

@ -33,6 +33,18 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
})
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
pages.GET("/messages", d.messageCtrl.MessagesPage)
// 帖子页面
pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage)
}
// 帖子页面(需登录)
postPages := r.Group("/posts")
postPages.Use(d.authMdw.Required())
{
postPages.GET("/new", d.postCtrl.NewPage)
postPages.GET("/:id/edit", d.postCtrl.EditPage)
}
// 认证页面(已登录自动跳走)

View File

@ -0,0 +1,212 @@
package service
import (
"bytes"
"fmt"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"github.com/yuin/goldmark"
)
// postStore 帖子服务所需的最小仓储接口ISP
type postStore interface {
Create(post *model.Post) error
FindByID(id uint) (*model.Post, error)
FindByIDWithAuthor(id uint) (*model.Post, error)
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error
SoftDelete(id uint) error
Restore(id uint) error
}
// PostService 帖子业务逻辑
type PostService struct {
repo postStore
ss *config.SiteSettings
md goldmark.Markdown
}
// NewPostService 构造函数
func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
return &PostService{
repo: repo,
ss: ss,
md: goldmark.New(),
}
}
// auditEnabled 审核是否开启(文案审核与全局审核开关一致)
func (s *PostService) auditEnabled() bool {
return s.ss.IsAuditEnabled()
}
// renderMarkdown 将 Markdown 渲染为 HTML
func (s *PostService) renderMarkdown(body string) (string, error) {
var buf bytes.Buffer
if err := s.md.Convert([]byte(body), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
// Create 创建帖子
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
bodyHTML, err := s.renderMarkdown(body)
if err != nil {
bodyHTML = body // 渲染失败降级使用纯文本
}
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
}
post := &model.Post{
Title: title,
Body: body,
BodyHTML: bodyHTML,
UserID: userID,
Status: status,
AllowComment: true,
}
if err := s.repo.Create(post); err != nil {
return nil, err
}
return post, nil
}
// GetByID 按 ID 获取帖子(含作者名,仅 approved 公开可见)
func (s *PostService) GetByID(id uint) (*model.Post, error) {
post, err := s.repo.FindByIDWithAuthor(id)
if err != nil {
return nil, common.ErrUserNotFound // 复用哨兵表示资源不存在
}
return post, nil
}
// List 公开帖子列表(仅 approved
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
}
// ListAdmin 管理后台帖子列表(全状态)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
}
// Update 编辑帖子(权限在 controller 层检查)
func (s *PostService) Update(postID uint, title, body string) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
// pending / locked 禁止编辑
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
return fmt.Errorf("当前状态不允许编辑")
}
post.Title = title
post.Body = body
bodyHTML, err := s.renderMarkdown(body)
if err != nil {
bodyHTML = body
}
post.BodyHTML = bodyHTML
// rejected 状态编辑后自动重置为 draft
if post.Status == model.PostStatusRejected {
post.Status = model.PostStatusDraft
post.RejectReason = ""
}
return s.repo.Update(post)
}
// Delete 软删除(权限在 controller 层检查)
func (s *PostService) Delete(postID uint) error {
return s.repo.SoftDelete(postID)
}
// SubmitForAudit 提交审核draft → pending
func (s *PostService) SubmitForAudit(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
return fmt.Errorf("仅草稿和已退回帖子可提交审核")
}
post.Status = model.PostStatusPending
return s.repo.Update(post)
}
// Approve 审核通过pending → approved
func (s *PostService) Approve(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusPending {
return fmt.Errorf("仅待审核帖子可通过")
}
post.Status = model.PostStatusApproved
post.RejectReason = ""
return s.repo.Update(post)
}
// Reject 退回pending/approved → rejected
func (s *PostService) Reject(postID uint, reason string) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
return fmt.Errorf("仅待审核和已发布帖子可退回")
}
post.Status = model.PostStatusRejected
post.RejectReason = reason
return s.repo.Update(post)
}
// Lock 锁定:任意状态 → locked
func (s *PostService) Lock(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
post.Status = model.PostStatusLocked
return s.repo.Update(post)
}
// Unlock 解锁locked → 草稿
func (s *PostService) Unlock(postID uint) error {
post, err := s.repo.FindByID(postID)
if err != nil {
return common.ErrUserNotFound
}
if post.Status != model.PostStatusLocked {
return fmt.Errorf("仅锁定状态可解锁")
}
post.Status = model.PostStatusDraft
return s.repo.Update(post)
}
// Restore 恢复软删除
func (s *PostService) Restore(postID uint) error {
return s.repo.Restore(postID)
}
// RenderMarkdown 公开的 Markdown 渲染(供预览 API 使用,不写 DB
func (s *PostService) RenderMarkdown(body string) (string, error) {
return s.renderMarkdown(body)
}