This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/internal/controller/post_controller.go
Victor_Jay 3f4a079c78 refactor: 彻底移除 /posts/new 旧编辑器,统一迁移至 /studio/write
- 删除 /posts/new 路由,不再保留任何兼容层
- /posts/:id/edit 改为 301 跳转 /studio/write?id=:id
- 删除 posts/new.html 模板和 editor.js
- 移除 PostController.EditPage/Create/Update/Delete 孤儿方法
- 移除 /api/posts POST/PUT/DELETE 孤儿 API(保留 submit 和 upload-image)
- posts/show.html 编辑链接指向 /studio/write
2026-05-31 15:35:20 +08:00

384 lines
10 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 (
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/chai2010/webp"
"github.com/gin-gonic/gin"
)
// PostController 帖子控制器SSR 页面 + API
type PostController struct {
postService postUseCase
shortcodeSvc *service.ShortcodeService
}
// NewPostController 构造函数
func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
return &PostController{postService: ps, shortcodeSvc: scs}
}
// 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
}
totalPages := common.PageCount(total, pageSize)
prevPage := page - 1
if prevPage < 1 {
prevPage = 1
}
nextPage := 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": 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": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
uid, role, _ := common.GetGinUser(c)
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
// 未登录用户:引导登录后回到当前帖子
if uid == 0 {
common.RedirectToLogin(c)
return
}
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
ctrl.applyShortcode(post)
// Open Graph 社交分享数据
ogImage := common.ExtractFirstImage(post.Body)
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
prefix := ""
if !strings.HasPrefix(ogImage, "/") {
prefix = "/"
}
ogImage = "https://" + c.Request.Host + prefix + ogImage
}
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
"Title": post.Title,
"Post": post,
"UID": uid,
"StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/static/css/posts.css",
"OgTitle": post.Title,
"OgDescription": post.Excerpt,
"OgImage": ogImage,
"OgURL": ogURL,
}))
}
// Submit API 提交审核
func (ctrl *PostController) Submit(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
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
}
if post.UserID != uid {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
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
}
common.Ok(c, model.PostListResult{
Items: posts,
Total: total,
Page: page,
TotalPages: common.PageCount(total, pageSize),
})
}
// 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 {
uid, role, _ := common.GetGinUser(c)
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
}
ctrl.applyShortcode(post)
common.Ok(c, post)
}
// allowedImageExt 允许的图片扩展名
var allowedImageExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}
// UploadImage 上传帖子图片需登录multipart/form-data
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
func (ctrl *PostController) UploadImage(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
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
}
// 读取全部数据(后续 WebP 转换需要)
data, err := io.ReadAll(file)
if err != nil {
common.Error(c, http.StatusInternalServerError, "读取文件失败")
return
}
// 存储路径
storageDir := "storage/uploads/posts"
if err := os.MkdirAll(storageDir, 0755); err != nil {
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
return
}
ts := time.Now().UnixMilli()
var savePath, url string
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
isWebP := ext == ".webp"
isGIF := ext == ".gif"
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
// JPEG/PNG解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小)
if isJPEGPNG {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
return
}
webpData := encodeWebPBinary(img, len(data))
if webpData != nil && len(webpData) < len(data) {
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
url = "/uploads/posts/" + filename
}
}
}
// GIF仅解码验证不转换
if isGIF {
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
return
}
}
// WebP仅解码验证不重复编码
if isWebP {
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
return
}
}
// 回退存储JPEG/PNG 重编码剥离元数据WebP 已验证直接存GIF 不重编(丢动画)
if savePath == "" {
dataOut := data
if isJPEGPNG {
decImg, _, decErr := image.Decode(bytes.NewReader(data))
if decErr == nil {
var buf bytes.Buffer
var encErr error
if ext == ".png" {
encErr = png.Encode(&buf, decImg)
} else {
encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
}
if encErr == nil && buf.Len() > 0 {
dataOut = buf.Bytes()
}
}
}
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath = filepath.Join(storageDir, filename)
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
common.Error(c, http.StatusInternalServerError, "保存图片失败")
return
}
url = "/uploads/posts/" + filename
}
common.VditorUploadOk(c, map[string]string{header.Filename: url})
}
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
func (ctrl *PostController) applyShortcode(post *model.Post) {
if ctrl.shortcodeSvc != nil {
result := ctrl.shortcodeSvc.Process(post.Body)
post.Body = result.ProcessedBody
}
}
// encodeWebPBinary 二分查找 WebP 质量,找 ≤ targetBytes 的最高质量quality 1~80
// 返回 nil 表示最低质量仍超限(几乎不可能发生),应由调用方回退原格式
func encodeWebPBinary(img image.Image, targetBytes int) []byte {
var buf bytes.Buffer
if err := webp.Encode(&buf, img, &webp.Options{Quality: 80}); err != nil {
return nil
}
if buf.Len() <= targetBytes {
return buf.Bytes()
}
low, high := 1, 80
var best []byte
for low <= high {
mid := (low + high) / 2
buf.Reset()
if err := webp.Encode(&buf, img, &webp.Options{Quality: float32(mid)}); err != nil {
return nil
}
if buf.Len() <= targetBytes {
best = make([]byte, buf.Len())
copy(best, buf.Bytes())
low = mid + 1
} else {
high = mid - 1
}
}
return best
}