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 图片语法 ![alt](url) +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}} {{.Title}} - MetaLab {{if .ExtraCSS}} diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html index ec67e51..480bc15 100644 --- a/templates/MetaLab-2026/html/layout/nav.html +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -14,7 +14,8 @@ -
  • {{.Username}}
  • +
  • {{.Username}}
  • +
  • 设置
  • 退出
  • {{else}}
  • 登录
  • diff --git a/templates/MetaLab-2026/html/posts/index.html b/templates/MetaLab-2026/html/posts/index.html index af22e0b..ccc0b9a 100644 --- a/templates/MetaLab-2026/html/posts/index.html +++ b/templates/MetaLab-2026/html/posts/index.html @@ -29,7 +29,7 @@ {{.Title}}
    - {{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}} + {{if .AuthorName}}{{.AuthorName}}{{else}}该用户已注销{{end}} {{.CreatedAt.Format "2006-01-02 15:04"}}

    {{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 @@
    + {{/* 状态栏 */}} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index f3a16c7..71da54e 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -8,7 +8,7 @@

    {{.Post.Title}}

    - + {{.Post.CreatedAt.Format "2006-01-02 15:04"}} {{if ne .Post.Status "approved"}} {{index $.StatusNames .Post.Status}} @@ -57,6 +57,18 @@ cdn: '/static/vditor', theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' }, hljs: { style: 'github-dark', enable: true }, + after: function() { + // 外部链接自动加 nofollow + target="_blank",避免权重流失 + var host = window.location.host; + var links = el.querySelectorAll('a[href^="http"]'); + for (var i = 0; i < links.length; i++) { + var a = links[i]; + if (a.host && a.host !== host) { + a.setAttribute('rel', 'nofollow noopener noreferrer'); + a.setAttribute('target', '_blank'); + } + } + } }); // Vditor.preview() 渲染 MD,shortcode 占位 div 原样保留 diff --git a/templates/MetaLab-2026/html/space/index.html b/templates/MetaLab-2026/html/space/index.html new file mode 100644 index 0000000..3566e60 --- /dev/null +++ b/templates/MetaLab-2026/html/space/index.html @@ -0,0 +1,93 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +{{if .Error}} +
    +
    +

    {{.Error}}

    + 返回首页 +
    +
    +{{else}} +
    + +
    +
    +
    + {{if .SpaceUser.Avatar}} + {{.SpaceUser.Username}} + {{else}} +
    {{slice .SpaceUser.Username 0 1}}
    + {{end}} +
    +
    +

    {{.SpaceUser.Username}}

    +

    {{if .SpaceUser.Bio}}{{.SpaceUser.Bio}}{{else}} {{end}}

    +
    + UID: {{.SpaceUser.ID}} + · + 加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}} +
    +
    +
    +
    + + +
    + +
    +
    + {{.Total}} + 稿件 +
    +
    + + +
    +

    稿件

    + + {{if eq (len .Posts) 0}} +
    暂无稿件
    + {{else}} +
    + {{range .Posts}} +
    +
    +

    + {{.Title}} +

    +

    {{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}

    + +
    +
    + {{end}} +
    + {{end}} + + + {{if gt .TotalPages 1}} + + {{end}} +
    +
    +
    +{{end}} + +{{template "layout/footer.html" .}} + + + + diff --git a/templates/MetaLab-2026/static/css/common.css b/templates/MetaLab-2026/static/css/common.css index eab78a9..c4e8ba0 100644 --- a/templates/MetaLab-2026/static/css/common.css +++ b/templates/MetaLab-2026/static/css/common.css @@ -26,13 +26,23 @@ box-sizing: border-box; } -html {} +html { + height: 100%; +} body { background: var(--color-bg); color: var(--color-text); line-height: 1.6; overflow-x: hidden; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* 主内容区撑满剩余空间,实现 sticky footer */ +body > .container { + flex: 1; } a { @@ -126,6 +136,11 @@ header { font-weight: 600 !important; } +.nav-settings { + font-size: .9rem; + color: var(--color-secondary) !important; +} + .nav-logout { color: var(--color-secondary) !important; } @@ -203,6 +218,7 @@ footer { background: var(--color-primary); color: #bdc3c7; padding: .8rem 0; + flex-shrink: 0; } .footer-content { diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index 8a1d28b..629afc3 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -297,7 +297,11 @@ height: calc(100vh - 56px); display: flex; flex-direction: column; - overflow: hidden; + overflow: visible; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 28px; } .editor-form { @@ -307,7 +311,7 @@ } .editor-header { - padding: 16px 24px 12px; + padding: 16px 0 12px; border-bottom: 1px solid #e5e7eb; background: #fff; flex-shrink: 0; @@ -330,21 +334,73 @@ .editor-main { flex: 1; display: flex; - overflow: hidden; + overflow: visible; min-height: 0; } .editor-pane { flex: 1; - overflow: hidden; + overflow: visible; min-width: 0; } +.editor-sidebar { + width: 250px; + flex-shrink: 0; + margin-left: 24px; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + font-size: 13px; + color: #4b5563; +} + +.editor-sidebar-section { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 14px 16px; +} + +.editor-sidebar-section h4 { + font-size: 12px; + font-weight: 600; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 10px 0; +} + +.editor-sidebar-section .tips-list { + list-style: none; + padding: 0; + margin: 0; +} + +.editor-sidebar-section .tips-list li { + padding: 5px 0; + border-bottom: 1px solid #f3f4f6; + line-height: 1.5; +} + +.editor-sidebar-section .tips-list li:last-child { + border-bottom: none; +} + +.editor-sidebar-section .tips-list code { + background: #f3f4f6; + padding: 1px 5px; + border-radius: 3px; + font-size: 12px; + color: #6366f1; +} + .editor-statusbar { display: flex; align-items: center; gap: 16px; - padding: 6px 20px; + padding: 6px 0; background: #fafafa; border-top: 1px solid #e5e7eb; font-size: 12px; @@ -364,32 +420,19 @@ z-index: 9999; height: 100vh; background: #fff; + max-width: none; } /* ========== Vditor 编辑器容器 ========== */ -/* Vditor 自动生成工具栏、编辑区、状态栏,仅做外层布局微调 */ +/* 仅提供高度约束,不干预 Vditor 内部布局和弹窗 */ #vditor { flex: 1; - overflow: hidden; min-height: 0; display: flex; flex-direction: column; } -/* Vditor 工具栏微调 — 匹配项目主题色 */ -.vditor-toolbar { - background: #fafafa !important; - border-bottom: 1px solid #e5e7eb !important; -} - -/* Vditor 内容区适配 */ -.vditor-content { - font-size: 15px; - line-height: 1.7; - color: #24292f; -} - /* Vditor 预览/渲染区域 — 详情页 */ .post-detail-body .vditor-reset { font-size: 16px; diff --git a/templates/MetaLab-2026/static/css/space.css b/templates/MetaLab-2026/static/css/space.css new file mode 100644 index 0000000..4f90998 --- /dev/null +++ b/templates/MetaLab-2026/static/css/space.css @@ -0,0 +1,313 @@ +/* ============================================================ + MetaLab Space Styles — 用户空间页面 + 参考 Bilibili 空间布局,轻量版 + ============================================================ */ + +/* ---------- Error State ---------- */ +.space-error { + text-align: center; + padding: 80px 20px; + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.space-error h1 { + font-size: 1.5rem; + color: var(--color-secondary); + margin-bottom: 1.5rem; +} + +.space-error .btn { + display: inline-block; + padding: .6rem 1.8rem; + background: var(--color-accent); + color: #fff; + border-radius: var(--radius); + font-weight: 600; + transition: var(--transition); +} + +.space-error .btn:hover { + background: #2980b9; + transform: translateY(-1px); +} + +/* ---------- Page Layout ---------- */ +.space-page { + flex: 1; +} + +/* ============================================================ + Header — 用户信息横幅(B站风格) + ============================================================ */ +.space-header { + background: linear-gradient(135deg, #e8f0f8 0%, #f0f4f8 100%); + border-bottom: 1px solid var(--color-border); + padding: 2rem 0; +} + +.space-header-inner { + display: flex; + align-items: center; + gap: 1.5rem; +} + +/* Avatar */ +.space-avatar { + flex-shrink: 0; +} + +.space-avatar img { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 3px solid #fff; + box-shadow: 0 2px 8px rgba(0,0,0,.1); +} + +.space-avatar-placeholder { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--color-accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 700; + border: 3px solid #fff; + box-shadow: 0 2px 8px rgba(0,0,0,.1); + text-transform: uppercase; +} + +/* Info */ +.space-info { + min-width: 0; +} + +.space-username { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-primary); + line-height: 1.3; + margin-bottom: .25rem; +} + +.space-bio { + font-size: .9rem; + color: var(--color-text-light); + line-height: 1.5; + margin-bottom: .5rem; + word-break: break-word; +} + +.space-meta { + display: flex; + align-items: center; + gap: .3rem; + font-size: .8rem; + color: var(--color-secondary); + flex-wrap: wrap; +} + +.space-divider { + color: var(--color-border); +} + +/* ============================================================ + Stats Bar + ============================================================ */ +.space-stats { + display: flex; + gap: 2rem; + padding: 1rem 0; + border-bottom: 1px solid var(--color-border); + margin-bottom: 1.5rem; +} + +.space-stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: .15rem; +} + +.space-stat-num { + font-size: 1.4rem; + font-weight: 700; + color: var(--color-primary); +} + +.space-stat-label { + font-size: .8rem; + color: var(--color-secondary); +} + +/* ============================================================ + Content Section — 稿件列表 + ============================================================ */ +.space-content { + padding-bottom: 3rem; +} + +.space-section { + margin-top: .5rem; +} + +.space-section-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-primary); + margin-bottom: 1rem; + padding-bottom: .5rem; + border-bottom: 2px solid var(--color-accent); + display: inline-block; +} + +/* ---------- Post Card ---------- */ +.space-posts-list { + display: flex; + flex-direction: column; + gap: .75rem; +} + +.space-post-card { + display: flex; + align-items: center; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + transition: box-shadow .2s ease, border-color .2s ease; +} + +.space-post-card:hover { + box-shadow: 0 2px 12px rgba(0,0,0,.06); + border-color: #ccc; +} + +.space-post-main { + flex: 1; + min-width: 0; +} + +.space-post-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: .3rem; + line-height: 1.4; +} + +.space-post-title a { + color: var(--color-primary); + transition: var(--transition); +} + +.space-post-title a:hover { + color: var(--color-accent); +} + +.space-post-excerpt { + font-size: .85rem; + color: var(--color-text-light); + line-height: 1.5; + margin-bottom: .5rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.space-post-meta { + display: flex; + align-items: center; + gap: .75rem; + font-size: .75rem; + color: var(--color-secondary); +} + +.space-post-status { + color: var(--color-accent); + font-weight: 500; +} + +/* ---------- Empty State ---------- */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--color-secondary); + font-size: .95rem; +} + +/* ---------- Pagination ---------- */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 2rem; + font-size: .9rem; +} + +.page-link { + padding: .4rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-accent); + transition: var(--transition); +} + +.page-link:hover { + border-color: var(--color-accent); + background: rgba(52, 152, 219, .05); +} + +.page-info { + color: var(--color-secondary); +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (max-width: 768px) { + .space-header { + padding: 1.5rem 0; + } + + .space-header-inner { + flex-direction: column; + text-align: center; + gap: .75rem; + } + + .space-avatar img, + .space-avatar-placeholder { + width: 64px; + height: 64px; + font-size: 1.5rem; + } + + .space-username { + font-size: 1.25rem; + } + + .space-meta { + justify-content: center; + } + + .space-stats { + justify-content: center; + } + + .space-post-card { + padding: .85rem 1rem; + } + + .space-post-title { + font-size: .95rem; + } +} diff --git a/templates/MetaLab-2026/static/js/common.js b/templates/MetaLab-2026/static/js/common.js index 09232ff..a4f9b0d 100644 --- a/templates/MetaLab-2026/static/js/common.js +++ b/templates/MetaLab-2026/static/js/common.js @@ -20,7 +20,7 @@ }; // ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ---- - window.MetaLab.csrfToken = getCSRFToken; + window.MetaLab.csrfToken = typeof getCSRFToken === 'function' ? getCSRFToken : function () { return ''; }; // ---- Auto-attach X-CSRF-Token to all state-changing requests ---- // Monkey-patch XMLHttpRequest and fetch to inject CSRF header @@ -31,7 +31,10 @@ var refreshPromise = null; var pendingRetries = []; + // 优先从 cookie 读取 CSRF token(与服务端验证源一致),meta 标签作为后备 function resolveCSRFToken() { + var cookieMatch = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/); + if (cookieMatch && cookieMatch[1]) return cookieMatch[1]; var meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute('content') : ''; } @@ -92,7 +95,11 @@ } // 401 interception for XHR + // 保存原始 onreadystatechange 后立即清除,防止原生属性重复触发 + // 否则浏览器原生 onreadystatechange 和 addEventListener 监听器会各触发一次 + // 导致 Vditor 图片上传等场景下图片被插入两次 var origOnReady = xhr.onreadystatechange; + xhr.onreadystatechange = null; xhr.addEventListener('readystatechange', function () { if (xhr.readyState === 4 && xhr.status === 401 && xhr._url !== '/api/auth/refresh' && diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index bba9b2b..79db4a8 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -17,9 +17,10 @@ let draftTimer = null let submitting = false // ---- CSRF Token ---- +// 直接从 cookie 读取 mlb_csrf(httpOnly=false),确保与服务端验证时读取的值一致 function getCsrfToken() { - const meta = document.querySelector('meta[name="csrf-token"]') - return meta ? meta.getAttribute('content') : '' + var match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/) + return match ? match[1] : '' } // ---- 初始化 ---- @@ -63,6 +64,10 @@ document.addEventListener('DOMContentLoaded', () => { 'outline', 'preview', 'devtools', ], + // Vditor 3.11.2 中 highlightToolbarWYSIWYG 直接调用此回调 + // 不提供会导致 TypeError: not a function,所有弹窗(代码块语言等)都不可用 + customWysiwygToolbar(type, popover) {}, + // 计数器 counter: { enable: true, @@ -94,27 +99,38 @@ document.addEventListener('DOMContentLoaded', () => { // 大纲 outline: { - enable: true, + enable: false, position: 'right', }, // 预览配置 + // 注意:WYSIWYG 编辑模式下代码块不做语法高亮(Vditor 3.11.2 源码中 + // highlightRender 会跳过 .vditor-wysiwyg__pre,仅在预览模式/详情页渲染) + // langs 确保语言选择下拉始终可用,不受 hljs 异步加载时机影响 preview: { theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' }, - hljs: { style: 'github-dark', enable: true }, + hljs: { + style: 'github-dark', + enable: true, + langs: [ + 'javascript', 'typescript', 'python', 'java', 'go', 'rust', + 'c', 'cpp', 'csharp', 'php', 'ruby', 'swift', 'kotlin', + 'html', 'css', 'scss', 'xml', 'json', 'yaml', 'markdown', + 'sql', 'bash', 'shell', 'powershell', 'dockerfile', 'nginx', + 'diff', 'http', 'graphql', 'makefile', + ], + }, markdown: { codeBlockPreview: true }, }, // 图片上传 + // CSRF token 由 common.js 的全局 XHR monkey-patch 自动注入,此处无需重复设置 + // 重复设置会导致浏览器将 header 合并为 "TOKEN, TOKEN" 从而验证失败 upload: { url: '/api/posts/upload-image', fieldName: 'file', max: 5 * 1024 * 1024, accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp', - // setHeaders 为函数,每次上传前重新读取 CSRF token - setHeaders() { - return { 'X-CSRF-Token': getCsrfToken() } - }, }, // 初始内容