+ {{.Title}} +
+{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
+ +diff --git a/docs/post-system-audit.md b/docs/post-system-audit.md new file mode 100644 index 0000000..fecdff7 --- /dev/null +++ b/docs/post-system-audit.md @@ -0,0 +1,231 @@ +# 帖子系统代码审计报告 + +> 审计日期:2026-05-30 +> 审计范围:`internal/` 下所有 Post 相关文件(router / controller / service / repository / model) +> 审计标准:DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范 + +--- + +## 一、架构合规性总览 + +| 检查项 | 状态 | 说明 | +|--------|------|------| +| 分层依赖方向 | ✅ | router→controller→service→repo→model,严格单向 | +| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` | +| Service Repository 接口 | ✅ | `service/repository.go`,8 个方法,无冗余 | +| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 | +| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 | +| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 | +| KISS | ✅ | 无过度设计 | +| YAGNI | ✅ | 无超前功能 | +| LoD | ✅ | 无跨层直接依赖 | + +--- + +## 二、问题清单 + +### 🔴 中等问题(4 个) + +#### 问题 1:Repository 方法代码重复(~70%) + +| 项 | 详情 | +|----|------| +| **文件** | `internal/repository/post_repo.go` | +| **位置** | `FindPageable`(行 49-73)vs `FindAdminPageable`(行 76-102) | +| **违反原则** | DRY | +| **描述** | 两个方法的核心查询逻辑(Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY)几乎完全一致,唯一区别是 `FindPageable` 固定过滤 `status = 'approved'`,`FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 | + +**现状:** +```go +// FindPageable (行 49-73) +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 过滤 */ } + // ... Count + Offset/Limit + Find +} + +// FindAdminPageable (行 76-102) +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") + // ← 无硬编码 status,由参数控制 + + if keyword != "" { /* LIKE 过滤 */ } + if status != "" { query = query.Where("posts.status = ?", status) } + // ... Count + Offset/Limit + Find +} +``` + +**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。 + +--- + +#### 问题 2:Service 方法重复 + +| 项 | 详情 | +|----|------| +| **文件** | `internal/service/post_service.go` | +| **位置** | `List`(行 148-156)vs `ListAdmin`(行 159-167) | +| **违反原则** | DRY | +| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 | + +**现状:** +```go +// List (行 148-156) +func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize) + if posts == nil { posts = []model.Post{} } + return posts, total, err +} + +// ListAdmin (行 159-167) +func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize) + if posts == nil { posts = []model.Post{} } + return posts, total, err +} +``` + +**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。 + +--- + +#### 问题 3:Controller 权限检查重复 6 处 + +| 项 | 详情 | +|----|------| +| **文件** | `internal/controller/post_controller.go` | +| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) | +| **违反原则** | DRY | +| **描述** | 以下模式在 6 个方法中逐字重复: | + +```go +post, err := ctrl.postService.GetByID(uint(id)) +if err != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return +} +if !model.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return +} +``` + +**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`。 + +--- + +#### 问题 4:Model 层职责过重 + +| 项 | 详情 | +|----|------| +| **文件** | `internal/model/post.go` | +| **位置** | 行 48-83 | +| **违反原则** | SRP(单一职责) | +| **描述** | 一个文件混合了多种职责: | + +| 内容 | 类型 | 应在位置 | +|------|------|---------| +| `Post` struct | 数据模型 | ✅ Model 层 | +| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 | +| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 | +| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` | +| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` | +| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` | +| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` | +| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 | + +**建议修复:** DTO 结构体移到 `model/dto.go`,`PostStatusDisplayNames` 移到 common 或 controller,`IsPostAccessible` 移到 service 层或独立权限模块。 + +--- + +### 🟡 轻微问题(3 个) + +#### 问题 5:工具函数位置不当 + +| 项 | 详情 | +|----|------| +| **文件** | `internal/controller/post_controller.go` | +| **位置** | `saveUploadedFile`(行 410-430) | +| **违反原则** | SRP / LoD | +| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 | + +**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。 + +--- + +#### 问题 6:Controller 层分页重复初始化 + +| 项 | 详情 | +|----|------| +| **文件** | `internal/controller/post_controller.go` | +| **位置** | `ListPage`(行 47-49)和 `ListAPI`(行 307-308) | +| **违反原则** | DRY | +| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 | + +```go +// controller 层重复的: +p := common.Pagination{Page: page, PageSize: pageSize} +p.DefaultPagination() +// ... 然后调用 service.List(),service 里又做了一遍 +``` + +**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`。 + +--- + +#### 问题 7:Admin Restore 错误分类缺失 + +| 项 | 详情 | +|----|------| +| **文件** | `internal/controller/admin/admin_post_controller.go` | +| **位置** | `Restore`(行 153-155) | +| **违反原则** | 一致性 | +| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound` 做 `errors.Is` 精确匹配返回 400,但 `Restore` 没有,所有错误统一返回 500。 | + +```go +// Restore — 缺少错误分类 +func (ctrl *AdminPostController) Restore(c *gin.Context) { + // ... + if err := ctrl.postService.Restore(id); err != nil { + // ← 此处应匹配 ErrPostNotFound,返回 400 而非 500 + common.Error(c, http.StatusInternalServerError, err.Error()) + return + } +} +``` + +**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。 + +--- + +## 三、亮点 + +1. **状态机设计清晰**:Post 状态流转(draft → pending → approved/rejected → locked)每个转换有明确前置条件检查和专用错误哨兵 +2. **接口设计优秀**:`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离 +3. **错误哨兵统一管理**:`common/errors.go` 集中定义所有 Post 相关错误 +4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制 +5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支 +6. **软删除 + 恢复**:完善的软删除和恢复机制 + +--- + +## 四、优先级建议 + +| 优先级 | 问题编号 | 原因 | +|--------|---------|------| +| P0 | — | 无阻塞性问题 | +| P1 | 1, 3 | Repository/Controller 重复影响维护成本 | +| P2 | 2, 4 | Service 重复 + Model 职责拆分 | +| P3 | 5, 6, 7 | 轻微优化项 | diff --git a/internal/common/context.go b/internal/common/context.go new file mode 100644 index 0000000..5120f53 --- /dev/null +++ b/internal/common/context.go @@ -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 +} diff --git a/internal/common/file.go b/internal/common/file.go new file mode 100644 index 0000000..d38e2df --- /dev/null +++ b/internal/common/file.go @@ -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 +} diff --git a/internal/common/helper.go b/internal/common/helper.go index 2b845b7..cc0f933 100644 --- a/internal/common/helper.go +++ b/internal/common/helper.go @@ -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{} diff --git a/internal/common/pagination.go b/internal/common/pagination.go index f566d71..4377a70 100644 --- a/internal/common/pagination.go +++ b/internal/common/pagination.go @@ -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 diff --git a/internal/common/post_helpers.go b/internal/common/post_helpers.go new file mode 100644 index 0000000..d9f2086 --- /dev/null +++ b/internal/common/post_helpers.go @@ -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: "已锁定", +} diff --git a/internal/controller/admin/admin_post_controller.go b/internal/controller/admin/admin_post_controller.go index 2b05ad4..e048305 100644 --- a/internal/controller/admin/admin_post_controller.go +++ b/internal/controller/admin/admin_post_controller.go @@ -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 } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index f272a77..df00d8d 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -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) } diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index 7fb09e8..03a3fa2 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -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 } + diff --git a/internal/controller/space_controller.go b/internal/controller/space_controller.go new file mode 100644 index 0000000..79014a1 --- /dev/null +++ b/internal/controller/space_controller.go @@ -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", + })) +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index 7a8e533..d5e5f35 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -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() } diff --git a/internal/model/dto.go b/internal/model/dto.go index 6139b6b..93787b3 100644 --- a/internal/model/dto.go +++ b/internal/model/dto.go @@ -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"` +} \ No newline at end of file diff --git a/internal/model/post.go b/internal/model/post.go index b5ca2b9..51e9285 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -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"` -} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 8398cf0..f80d54e 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -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 } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index f8cb69d..8bda433 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -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 diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index fdb7f0c..ca82d21 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -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, } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index a136ff9..48cb4c5 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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) diff --git a/internal/service/post_service.go b/internal/service/post_service.go index ec46915..6c45eaa 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -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) } diff --git a/internal/service/repository.go b/internal/service/repository.go index 6c4c2a5..00194df 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -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) } \ No newline at end of file diff --git a/internal/service/space_service.go b/internal/service/space_service.go new file mode 100644 index 0000000..90aa6fc --- /dev/null +++ b/internal/service/space_service.go @@ -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) +} diff --git a/templates/MetaLab-2026/html/layout/header.html b/templates/MetaLab-2026/html/layout/header.html index 81fc2d7..07dfc4c 100644 --- a/templates/MetaLab-2026/html/layout/header.html +++ b/templates/MetaLab-2026/html/layout/header.html @@ -4,6 +4,14 @@ {{if .CSRFToken}}{{end}} +{{if .OgTitle}} + + + +{{if .OgDescription}}{{end}} +{{if .OgImage}}{{end}} +{{if .OgURL}}{{end}} +{{end}}
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index 4f072db..f42f2f9 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -24,6 +24,30 @@