diff --git a/cmd/server/main.go b/cmd/server/main.go index 758664e..374c9a5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,7 +25,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/go.mod b/go.mod index ecbb6d0..dee3038 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yuin/goldmark v1.8.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect diff --git a/go.sum b/go.sum index 1fddb81..a56cb82 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/internal/controller/admin/admin_post_controller.go b/internal/controller/admin/admin_post_controller.go new file mode 100644 index 0000000..22bf7e8 --- /dev/null +++ b/internal/controller/admin/admin_post_controller.go @@ -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, "已恢复") +} diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go index c6bc09a..3768887 100644 --- a/internal/controller/admin/interfaces.go +++ b/internal/controller/admin/interfaces.go @@ -32,3 +32,13 @@ type siteSettingUseCase interface { type auditStatusProvider interface { FindByID(id uint) (*model.User, error) } + +// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:6 个方法) +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 +} diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 7b2c2fb..417442b 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -24,3 +24,14 @@ type rateLimiter interface { AllowIP(ip string) (middleware.RateLimitResult, func()) Clear(email, ip string) } + +// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法) +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) +} diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go new file mode 100644 index 0000000..a007eba --- /dev/null +++ b/internal/controller/post_controller.go @@ -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}) +} diff --git a/internal/model/post.go b/internal/model/post.go new file mode 100644 index 0000000..7c0de38 --- /dev/null +++ b/internal/model/post.go @@ -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"` +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go new file mode 100644 index 0000000..8398cf0 --- /dev/null +++ b/internal/repository/post_repo.go @@ -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 +} diff --git a/internal/router/admin.go b/internal/router/admin.go index 7ec1818..6203d5e 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -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) } + + // --- 帖子管理 API(admin+) --- + 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) + } } diff --git a/internal/router/api.go b/internal/router/api.go index 841999a..9fa5f90 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -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) } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 5fbf3c2..f8cb69d 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -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 diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 685abc0..b941dcf 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -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, } } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index ffd9ae3..09a796f 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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) } // 认证页面(已登录自动跳走) diff --git a/internal/service/post_service.go b/internal/service/post_service.go new file mode 100644 index 0000000..60e1406 --- /dev/null +++ b/internal/service/post_service.go @@ -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) +} diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html index f6d13ee..ec67e51 100644 --- a/templates/MetaLab-2026/html/layout/nav.html +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -6,6 +6,7 @@