feat: 编辑器与帖子展示布局优化 + 新增个人空间
- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐 - 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调 - CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403 - Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议 - 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问 - 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表 - 导航栏:用户名链接指向 /space,新增「设置」入口 - 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
This commit is contained in:
20
internal/common/context.go
Normal file
20
internal/common/context.go
Normal file
@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
||||
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
||||
func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
|
||||
if v, exists := c.Get("uid"); exists {
|
||||
if u, isUint := v.(uint); isUint {
|
||||
uid = u
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if v, exists := c.Get("role"); exists {
|
||||
if r, isStr := v.(string); isStr {
|
||||
role = r
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
29
internal/common/file.go
Normal file
29
internal/common/file.go
Normal file
@ -0,0 +1,29 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"os"
|
||||
)
|
||||
|
||||
// SaveUploadedFile 将 multipart.File 内容写入目标路径
|
||||
func SaveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
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
|
||||
}
|
||||
@ -1,11 +1,25 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// mdImageRe 匹配 Markdown 图片语法 
|
||||
var mdImageRe = regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
|
||||
|
||||
// ExtractFirstImage 从 Markdown 正文提取第一张图片 URL
|
||||
func ExtractFirstImage(md string) string {
|
||||
match := mdImageRe.FindStringSubmatch(md)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
|
||||
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
data := gin.H{}
|
||||
|
||||
@ -49,5 +49,13 @@ func (p *Pagination) NextPage(totalPages int) int {
|
||||
return next
|
||||
}
|
||||
|
||||
// PageCount 根据 total 和 pageSize 计算总页数(无需创建 Pagination 实例)
|
||||
func PageCount(total int64, pageSize int) int {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
|
||||
// 固定时间格式,前后端统一
|
||||
const TimeFormat = time.RFC3339
|
||||
|
||||
12
internal/common/post_helpers.go
Normal file
12
internal/common/post_helpers.go
Normal file
@ -0,0 +1,12 @@
|
||||
package common
|
||||
|
||||
import "metazone.cc/metalab/internal/model"
|
||||
|
||||
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
|
||||
var PostStatusDisplayNames = map[string]string{
|
||||
model.PostStatusDraft: "草稿",
|
||||
model.PostStatusPending: "待审核",
|
||||
model.PostStatusApproved: "已发布",
|
||||
model.PostStatusRejected: "已退回",
|
||||
model.PostStatusLocked: "已锁定",
|
||||
}
|
||||
@ -37,25 +37,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
totalPages := common.PageCount(total, pageSize)
|
||||
prevPage := page - 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
nextPage := page + 1
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
totalPages := p.TotalPages(total)
|
||||
|
||||
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "内容管理",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": p.Page,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": p.PrevPage(),
|
||||
"NextPage": p.NextPage(totalPages),
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"Keyword": keyword,
|
||||
"Status": status,
|
||||
"StatusNames": model.PostStatusDisplayNames,
|
||||
"StatusNames": common.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/admin/static/css/posts.css",
|
||||
}))
|
||||
}
|
||||
@ -155,7 +157,11 @@ func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||
if errors.Is(err, common.ErrPostNotFound) {
|
||||
common.Error(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ type rateLimiter interface {
|
||||
Clear(email, ip string)
|
||||
}
|
||||
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:7 个方法)
|
||||
type postUseCase interface {
|
||||
Create(userID uint, title, body string) (*model.Post, error)
|
||||
GetByID(id uint) (*model.Post, error)
|
||||
@ -33,4 +33,11 @@ type postUseCase interface {
|
||||
Update(postID uint, title, body string) error
|
||||
Delete(postID uint) error
|
||||
SubmitForAudit(postID uint) error
|
||||
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||
}
|
||||
|
||||
// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:2 个方法)
|
||||
type spaceUseCase interface {
|
||||
GetSpaceUser(uid uint) (*model.User, error)
|
||||
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -44,22 +43,24 @@ func (ctrl *PostController) ListPage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
totalPages := common.PageCount(total, pageSize)
|
||||
prevPage := page - 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
nextPage := page + 1
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
totalPages := p.TotalPages(total)
|
||||
|
||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "社区帖子",
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": p.Page,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": p.PrevPage(),
|
||||
"NextPage": p.NextPage(totalPages),
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"Keyword": keyword,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
}))
|
||||
@ -83,42 +84,39 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
uid, role, _ := common.GetGinUser(c)
|
||||
|
||||
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||||
if post.Status != model.PostStatusApproved {
|
||||
isAuthor := uid == post.UserID
|
||||
isModerator := model.HasMinRole(role, model.RoleModerator)
|
||||
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if !isAuthor && !isModerator {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
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
|
||||
}
|
||||
|
||||
// 处理 shortcode:[zone:type:params] → HTML 占位符
|
||||
// 占位 div 会被 Vditor.preview() 保留,由前端 shortcode.js 渲染为卡片
|
||||
if ctrl.shortcodeSvc != nil {
|
||||
result := ctrl.shortcodeSvc.Process(post.Body)
|
||||
post.Body = result.ProcessedBody
|
||||
}
|
||||
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": model.PostStatusDisplayNames,
|
||||
"ExtraCSS": "/static/css/posts.css",
|
||||
"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,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -148,16 +146,8 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
|
||||
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) {
|
||||
uid, role, _ := common.GetGinUser(c)
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "未找到",
|
||||
}))
|
||||
@ -173,8 +163,7 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
|
||||
|
||||
// Create API 发帖
|
||||
func (ctrl *PostController) Create(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
@ -197,8 +186,7 @@ func (ctrl *PostController) Create(c *gin.Context) {
|
||||
|
||||
// Update API 编辑帖子
|
||||
func (ctrl *PostController) Update(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
uid, role, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
@ -216,16 +204,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
|
||||
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, "无权编辑此帖子")
|
||||
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@ -243,8 +222,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
|
||||
|
||||
// Delete API 删除帖子
|
||||
func (ctrl *PostController) Delete(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
uid, role, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
@ -256,16 +234,7 @@ func (ctrl *PostController) Delete(c *gin.Context) {
|
||||
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, "无权删除此帖子")
|
||||
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@ -277,10 +246,24 @@ func (ctrl *PostController) Delete(c *gin.Context) {
|
||||
common.OkMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// getPostAndCheckAccess 获取帖子并检查当前用户权限
|
||||
// 返回 (*model.Post, true) 表示通过;返回 (nil, false) 表示已写入错误响应
|
||||
func (ctrl *PostController) getPostAndCheckAccess(id uint, c *gin.Context, uid uint, role string) (*model.Post, bool) {
|
||||
post, err := ctrl.postService.GetByID(id)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return nil, false
|
||||
}
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||
return nil, false
|
||||
}
|
||||
return post, true
|
||||
}
|
||||
|
||||
// Submit API 提交审核
|
||||
func (ctrl *PostController) Submit(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
@ -292,12 +275,12 @@ func (ctrl *PostController) Submit(c *gin.Context) {
|
||||
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
|
||||
@ -327,18 +310,11 @@ func (ctrl *PostController) ListAPI(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
common.Ok(c, model.PostListResult{
|
||||
Items: posts,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: p.TotalPages(total),
|
||||
Page: page,
|
||||
TotalPages: common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
@ -358,27 +334,14 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
||||
|
||||
// 权限控制:非 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 {
|
||||
uid, role, _ := common.GetGinUser(c)
|
||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 shortcode:[zone:type:params] → HTML 占位符
|
||||
if ctrl.shortcodeSvc != nil {
|
||||
result := ctrl.shortcodeSvc.Process(post.Body)
|
||||
post.Body = result.ProcessedBody
|
||||
}
|
||||
ctrl.applyShortcode(post)
|
||||
|
||||
common.Ok(c, post)
|
||||
}
|
||||
@ -390,8 +353,7 @@ var allowedImageExt = map[string]bool{
|
||||
|
||||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
uidObj, _ := c.Get("uid")
|
||||
uid, ok := uidObj.(uint)
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
@ -430,7 +392,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||
savePath := filepath.Join(storageDir, filename)
|
||||
|
||||
if err := saveUploadedFile(file, savePath); err != nil {
|
||||
if err := common.SaveUploadedFile(file, savePath); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||
return
|
||||
}
|
||||
@ -439,26 +401,11 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
||||
}
|
||||
|
||||
// saveUploadedFile 将 multipart.File 写入目标路径
|
||||
func saveUploadedFile(file multipart.File, dst string) error {
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
||||
func (ctrl *PostController) applyShortcode(post *model.Post) {
|
||||
if ctrl.shortcodeSvc != nil {
|
||||
result := ctrl.shortcodeSvc.Process(post.Body)
|
||||
post.Body = result.ProcessedBody
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
103
internal/controller/space_controller.go
Normal file
103
internal/controller/space_controller.go
Normal file
@ -0,0 +1,103 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SpaceController 用户空间控制器
|
||||
type SpaceController struct {
|
||||
spaceService spaceUseCase
|
||||
}
|
||||
|
||||
// NewSpaceController 构造函数
|
||||
func NewSpaceController(spaceService spaceUseCase) *SpaceController {
|
||||
return &SpaceController{spaceService: spaceService}
|
||||
}
|
||||
|
||||
// MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid}
|
||||
func (ctrl *SpaceController) MySpace(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, "/auth/login")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
|
||||
}
|
||||
|
||||
// ShowSpace 查看他人或自己的空间(/space/:uid)— 无需认证
|
||||
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
||||
uidStr := c.Param("uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 64)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "用户不存在",
|
||||
"Error": "用户不存在",
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
spaceUser, err := ctrl.spaceService.GetSpaceUser(uint(uid))
|
||||
if err != nil || spaceUser == nil {
|
||||
c.HTML(http.StatusNotFound, "space/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "用户不存在",
|
||||
"Error": "用户不存在",
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// 分页
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
if pageSize < 1 || pageSize > 50 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
posts, total, err := ctrl.spaceService.GetPostsByUser(uint(uid), page, pageSize)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "space/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": "加载失败",
|
||||
"Error": "加载帖子失败,请稍后重试",
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := common.PageCount(total, pageSize)
|
||||
prevPage := page - 1
|
||||
if prevPage < 1 {
|
||||
prevPage = 1
|
||||
}
|
||||
nextPage := page + 1
|
||||
if nextPage > totalPages {
|
||||
nextPage = totalPages
|
||||
}
|
||||
|
||||
// 检查是否是自己的空间
|
||||
isOwnSpace := false
|
||||
if currentUID, _, ok := common.GetGinUser(c); ok {
|
||||
isOwnSpace = currentUID == uint(uid)
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, gin.H{
|
||||
"Title": spaceUser.Username + " 的空间",
|
||||
"SpaceUser": spaceUser,
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"PrevPage": prevPage,
|
||||
"NextPage": nextPage,
|
||||
"IsOwnSpace": isOwnSpace,
|
||||
"ExtraCSS": "/static/css/space.css",
|
||||
}))
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/config"
|
||||
@ -44,12 +45,16 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if !constantTimeEq(cookieToken, headerToken) {
|
||||
log.Printf("[CSRF] MISMATCH | path=%s | cookie(len=%d)=%q | header(len=%d)=%q",
|
||||
c.Request.URL.Path, len(cookieToken), cookieToken, len(headerToken), headerToken)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"success": false, "message": "CSRF 验证失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[CSRF] OK | path=%s | token(len=%d)=%q", c.Request.URL.Path, len(cookieToken), cookieToken)
|
||||
|
||||
c.Set(csrfMetaName, cookieToken)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@ -20,4 +20,37 @@ type CheckEmailRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// ---- Post DTOs ----
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@ -34,45 +34,3 @@ const (
|
||||
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"`
|
||||
}
|
||||
|
||||
@ -45,35 +45,8 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
|
||||
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) {
|
||||
// buildQuery 构建帖子查询公共部分(联表 + 未删除 + 关键词 + 可选状态过滤)
|
||||
func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
|
||||
query := r.db.Table("posts").
|
||||
Select("posts.*, users.username as author_name").
|
||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||
@ -86,7 +59,30 @@ func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int)
|
||||
if status != "" {
|
||||
query = query.Where("posts.status = ?", status)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题)
|
||||
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.buildQuery(keyword, model.PostStatusApproved)
|
||||
return r.pageResults(query, offset, limit)
|
||||
}
|
||||
|
||||
// FindAdminPageable 管理后台分页查询(全状态筛选)
|
||||
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.buildQuery(keyword, status)
|
||||
return r.pageResults(query, offset, limit)
|
||||
}
|
||||
|
||||
// FindByUserID 分页查询某用户发布的帖子(仅 approved,含作者用户名)
|
||||
func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error) {
|
||||
query := r.buildQuery("", model.PostStatusApproved).
|
||||
Where("posts.user_id = ?", userID)
|
||||
return r.pageResults(query, offset, limit)
|
||||
}
|
||||
|
||||
// pageResults 执行 Count + Offset/Limit + ORDER BY
|
||||
func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) {
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
@ -113,5 +109,12 @@ func (r *PostRepo) SoftDelete(id uint) error {
|
||||
|
||||
// Restore 恢复软删除
|
||||
func (r *PostRepo) Restore(id uint) error {
|
||||
return r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
|
||||
result := r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ type dependencies struct {
|
||||
settingsCtrl *controller.SettingsController
|
||||
messageCtrl *controller.MessageController
|
||||
postCtrl *controller.PostController
|
||||
spaceCtrl *controller.SpaceController
|
||||
adminPostCtrl *adminCtrl.AdminPostController
|
||||
adminCtrl *adminCtrl.AdminController
|
||||
auditCtrl *adminCtrl.AuditController
|
||||
|
||||
@ -36,9 +36,14 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
||||
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
||||
|
||||
// 用户空间
|
||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||
|
||||
return &dependencies{
|
||||
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
|
||||
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl,
|
||||
messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
|
||||
adminPostCtrl: adminPostCtrl,
|
||||
adminCtrl: adminController, auditCtrl: auditController,
|
||||
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
|
||||
}
|
||||
|
||||
@ -34,6 +34,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
|
||||
pages.GET("/messages", d.messageCtrl.MessagesPage)
|
||||
|
||||
// 用户空间(静态 /space 必须在 :uid 之前注册)
|
||||
pages.GET("/space", d.spaceCtrl.MySpace)
|
||||
pages.GET("/space/:uid", d.spaceCtrl.ShowSpace)
|
||||
|
||||
// 帖子页面
|
||||
pages.GET("/posts", d.postCtrl.ListPage)
|
||||
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
|
||||
|
||||
@ -144,18 +144,29 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
return s.findPostWithAuthor(id)
|
||||
}
|
||||
|
||||
// List 公开帖子列表(仅 approved)
|
||||
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
// listPageable 分页查询公共逻辑:参数规范化 + nil 转空切片
|
||||
func (s *PostService) listPageable(page, pageSize int, fn func(offset, limit int) ([]model.Post, int64, error)) ([]model.Post, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
return s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
||||
posts, total, err := fn(p.Offset(), p.PageSize)
|
||||
if posts == nil {
|
||||
posts = []model.Post{}
|
||||
}
|
||||
return posts, total, err
|
||||
}
|
||||
|
||||
// List 公开帖子列表(仅 approved)
|
||||
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||
return s.repo.FindPageable(keyword, offset, limit)
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||
return s.repo.FindAdminPageable(keyword, status, offset, limit)
|
||||
})
|
||||
}
|
||||
|
||||
// Update 编辑帖子(权限在 controller 层检查)
|
||||
@ -253,5 +264,17 @@ func (s *PostService) Unlock(postID uint) error {
|
||||
|
||||
// Restore 恢复软删除
|
||||
func (s *PostService) Restore(postID uint) error {
|
||||
return s.repo.Restore(postID)
|
||||
err := s.repo.Restore(postID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+)
|
||||
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
||||
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
||||
}
|
||||
|
||||
@ -41,4 +41,14 @@ type postStore interface {
|
||||
Update(post *model.Post) error
|
||||
SoftDelete(id uint) error
|
||||
Restore(id uint) error
|
||||
}
|
||||
|
||||
// spaceUserStore SpaceService 所需的最小用户仓储接口(ISP:1 个方法)
|
||||
type spaceUserStore interface {
|
||||
FindByID(id uint) (*model.User, error)
|
||||
}
|
||||
|
||||
// spacePostStore SpaceService 所需的最小帖子仓储接口(ISP:1 个方法)
|
||||
type spacePostStore interface {
|
||||
FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error)
|
||||
}
|
||||
25
internal/service/space_service.go
Normal file
25
internal/service/space_service.go
Normal file
@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import "metazone.cc/metalab/internal/model"
|
||||
|
||||
// SpaceService 用户空间服务
|
||||
type SpaceService struct {
|
||||
userRepo spaceUserStore
|
||||
postRepo spacePostStore
|
||||
}
|
||||
|
||||
// NewSpaceService 构造函数
|
||||
func NewSpaceService(userRepo spaceUserStore, postRepo spacePostStore) *SpaceService {
|
||||
return &SpaceService{userRepo: userRepo, postRepo: postRepo}
|
||||
}
|
||||
|
||||
// GetSpaceUser 获取用户信息
|
||||
func (s *SpaceService) GetSpaceUser(uid uint) (*model.User, error) {
|
||||
return s.userRepo.FindByID(uid)
|
||||
}
|
||||
|
||||
// GetPostsByUser 获取用户发布的帖子(分页)
|
||||
func (s *SpaceService) GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
return s.postRepo.FindByUserID(uid, offset, pageSize)
|
||||
}
|
||||
Reference in New Issue
Block a user