Files
mce/internal/controller/post_controller.go
Victor_Jay e45d66fb77 fix: 修复帖子相关逻辑的严重安全与错误处理问题
- 修复 Service 层所有 FindByID 错误均返回 ErrUserNotFound 的严重 bug,新增 findPost/findPostWithAuthor 正确区分 gorm.ErrRecordNotFound 和数据库错误
- 新增帖子相关错误哨兵:ErrPostNotFound/ErrPostCannotEdit/ErrPostCannotSubmit/ErrPostCannotApprove/ErrPostCannotReject/ErrPostCannotUnlock
- 修复 ShowAPI 缺少权限控制,非 approved 帖子可被任意用户通过 API 获取
- 修复 EditPage 缺少权限检查,非作者可访问编辑页面
- 修复 Update/Delete API 中 uid 断言未做 ok 检查
- Controller 层区分业务错误和内部错误,避免透传 Service 层错误信息给客户端
- 将 postStore 接口从 post_service.go 移至 repository.go,与其他仓储接口保持一致
- 移除 new.html 中孤立的 </template> 标签
2026-05-28 14:44:22 +08:00

439 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"errors"
"fmt"
"html/template"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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,
"PostBodyHTML": template.HTML(post.BodyHTML),
"UID": uid,
"StatusNames": model.PostStatusDisplayNames,
"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",
}))
}
// 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
}
// 权限检查:仅作者和 moderator+ 可编辑
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)
}
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
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",
}))
}
// 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, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
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 {
if errors.Is(err, common.ErrPostCannotEdit) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "编辑失败")
}
return
}
common.OkMessage(c, "编辑成功")
}
// Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
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 {
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "提交审核失败")
}
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
}
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved {
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)
}
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
}
common.Ok(c, post)
}
// allowedImageExt 允许的图片扩展名
var allowedImageExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}
// UploadImage 上传帖子图片需登录multipart/form-data
func (ctrl *PostController) UploadImage(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
// 检查扩展名
ext := strings.ToLower(filepath.Ext(header.Filename))
if !allowedImageExt[ext] {
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
return
}
// 限制 5MB
maxSize := int64(5 << 20)
if header.Size > maxSize {
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
return
}
// 存储路径
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
// 唯一文件名uid_timestamp.ext
ts := time.Now().UnixMilli()
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath := filepath.Join(storageDir, filename)
if err := saveUploadedFile(file, savePath); err != nil {
common.Error(c, http.StatusInternalServerError, "保存图片失败")
return
}
url := "/uploads/posts/" + filename
common.Ok(c, gin.H{"url": url})
}
// saveUploadedFile 将 multipart.File 写入目标路径
func saveUploadedFile(file multipart.File, dst string) error {
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
// 限制读取 5MB 防止内存放大
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
}
if err != nil {
break
}
}
return nil
}