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:
2026-05-30 19:06:10 +08:00
parent 40f0529cbf
commit bacfd4945d
31 changed files with 1229 additions and 252 deletions

231
docs/post-system-audit.md Normal file
View File

@ -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 个)
#### 问题 1Repository 方法代码重复(~70%
| 项 | 详情 |
|----|------|
| **文件** | `internal/repository/post_repo.go` |
| **位置** | `FindPageable`(行 49-73vs `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 条件。
---
#### 问题 2Service 方法重复
| 项 | 详情 |
|----|------|
| **文件** | `internal/service/post_service.go` |
| **位置** | `List`(行 148-156vs `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 检查逻辑。
---
#### 问题 3Controller 权限检查重复 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)`
---
#### 问题 4Model 层职责过重
| 项 | 详情 |
|----|------|
| **文件** | `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/` 包。
---
#### 问题 6Controller 层分页重复初始化
| 项 | 详情 |
|----|------|
| **文件** | `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()`
---
#### 问题 7Admin 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 | 轻微优化项 |

View File

@ -0,0 +1,20 @@
package common
import "github.com/gin-gonic/gin"
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
// 如果用户未登录ok 返回 falserole 可能为空字符串
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
View 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
}

View File

@ -1,11 +1,25 @@
package common package common
import ( import (
"regexp"
"metazone.cc/metalab/internal/model" "metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin" "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 // BuildPageData 构建页面模板数据,自动注入登录状态、站点信息与 CSRF token
func BuildPageData(c *gin.Context, extra gin.H) gin.H { func BuildPageData(c *gin.Context, extra gin.H) gin.H {
data := gin.H{} data := gin.H{}

View File

@ -49,5 +49,13 @@ func (p *Pagination) NextPage(totalPages int) int {
return next 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 const TimeFormat = time.RFC3339

View 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: "已锁定",
}

View File

@ -37,25 +37,27 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
return return
} }
if posts == nil { totalPages := common.PageCount(total, pageSize)
posts = []model.Post{} 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{ c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "内容管理", "Title": "内容管理",
"Posts": posts, "Posts": posts,
"Total": total, "Total": total,
"Page": p.Page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevPage": p.PrevPage(), "PrevPage": prevPage,
"NextPage": p.NextPage(totalPages), "NextPage": nextPage,
"Keyword": keyword, "Keyword": keyword,
"Status": status, "Status": status,
"StatusNames": model.PostStatusDisplayNames, "StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/admin/static/css/posts.css", "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 { 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 return
} }

View File

@ -25,7 +25,7 @@ type rateLimiter interface {
Clear(email, ip string) Clear(email, ip string)
} }
// postUseCase PostController 对 PostService 的最小依赖ISP6 个方法) // postUseCase PostController 对 PostService 的最小依赖ISP7 个方法)
type postUseCase interface { type postUseCase interface {
Create(userID uint, title, body string) (*model.Post, error) Create(userID uint, title, body string) (*model.Post, error)
GetByID(id uint) (*model.Post, error) GetByID(id uint) (*model.Post, error)
@ -33,4 +33,11 @@ type postUseCase interface {
Update(postID uint, title, body string) error Update(postID uint, title, body string) error
Delete(postID uint) error Delete(postID uint) error
SubmitForAudit(postID uint) error SubmitForAudit(postID uint) error
IsPostAccessible(userID uint, role string, postUserID uint) bool
}
// spaceUseCase SpaceController 对 SpaceService 的最小依赖ISP2 个方法)
type spaceUseCase interface {
GetSpaceUser(uid uint) (*model.User, error)
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
} }

View File

@ -3,7 +3,6 @@ package controller
import ( import (
"errors" "errors"
"fmt" "fmt"
"mime/multipart"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -44,22 +43,24 @@ func (ctrl *PostController) ListPage(c *gin.Context) {
return return
} }
if posts == nil { totalPages := common.PageCount(total, pageSize)
posts = []model.Post{} 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{ c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
"Title": "社区帖子", "Title": "社区帖子",
"Posts": posts, "Posts": posts,
"Total": total, "Total": total,
"Page": p.Page, "Page": page,
"TotalPages": totalPages, "TotalPages": totalPages,
"PrevPage": p.PrevPage(), "PrevPage": prevPage,
"NextPage": p.NextPage(totalPages), "NextPage": nextPage,
"Keyword": keyword, "Keyword": keyword,
"ExtraCSS": "/static/css/posts.css", "ExtraCSS": "/static/css/posts.css",
})) }))
@ -83,42 +84,39 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
return return
} }
// 获取当前用户信息 uid, role, _ := common.GetGinUser(c)
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态) // 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
if post.Status != model.PostStatusApproved { if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
isAuthor := uid == post.UserID c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
isModerator := model.HasMinRole(role, model.RoleModerator) "Title": "未找到",
}))
return
}
if !isAuthor && !isModerator { ctrl.applyShortcode(post)
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到", // Open Graph 社交分享数据
})) ogImage := common.ExtractFirstImage(post.Body)
return if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
prefix := ""
if !strings.HasPrefix(ogImage, "/") {
prefix = "/"
} }
ogImage = "https://" + c.Request.Host + prefix + ogImage
} }
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
// 处理 shortcode[zone:type:params] → HTML 占位符
// 占位 div 会被 Vditor.preview() 保留,由前端 shortcode.js 渲染为卡片
if ctrl.shortcodeSvc != nil {
result := ctrl.shortcodeSvc.Process(post.Body)
post.Body = result.ProcessedBody
}
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
"Title": post.Title, "Title": post.Title,
"Post": post, "Post": post,
"UID": uid, "UID": uid,
"StatusNames": model.PostStatusDisplayNames, "StatusNames": common.PostStatusDisplayNames,
"ExtraCSS": "/static/css/posts.css", "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 return
} }
// 权限检查:仅作者和 moderator+ 可编辑 uid, role, _ := common.GetGinUser(c)
var uid uint if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到", "Title": "未找到",
})) }))
@ -173,8 +163,7 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
// Create API 发帖 // Create API 发帖
func (ctrl *PostController) Create(c *gin.Context) { func (ctrl *PostController) Create(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, _, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -197,8 +186,7 @@ func (ctrl *PostController) Create(c *gin.Context) {
// Update API 编辑帖子 // Update API 编辑帖子
func (ctrl *PostController) Update(c *gin.Context) { func (ctrl *PostController) Update(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, role, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -216,16 +204,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
return return
} }
// 权限检查:取帖子归属 if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
post, err := ctrl.postService.GetByID(uint(id))
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
return return
} }
@ -243,8 +222,7 @@ func (ctrl *PostController) Update(c *gin.Context) {
// Delete API 删除帖子 // Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) { func (ctrl *PostController) Delete(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, role, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -256,16 +234,7 @@ func (ctrl *PostController) Delete(c *gin.Context) {
return return
} }
post, err := ctrl.postService.GetByID(uint(id)) if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
roleObj, _ := c.Get("role")
role, _ := roleObj.(string)
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
common.Error(c, http.StatusForbidden, "无权删除此帖子")
return return
} }
@ -277,10 +246,24 @@ func (ctrl *PostController) Delete(c *gin.Context) {
common.OkMessage(c, "删除成功") 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 提交审核 // Submit API 提交审核
func (ctrl *PostController) Submit(c *gin.Context) { func (ctrl *PostController) Submit(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, _, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -292,12 +275,12 @@ func (ctrl *PostController) Submit(c *gin.Context) {
return return
} }
// 权限检查:仅帖子作者可提交审核
post, err := ctrl.postService.GetByID(uint(id)) post, err := ctrl.postService.GetByID(uint(id))
if err != nil { if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在") common.Error(c, http.StatusNotFound, "帖子不存在")
return return
} }
if post.UserID != uid { if post.UserID != uid {
common.Error(c, http.StatusForbidden, "无权操作此帖子") common.Error(c, http.StatusForbidden, "无权操作此帖子")
return return
@ -327,18 +310,11 @@ func (ctrl *PostController) ListAPI(c *gin.Context) {
return return
} }
if posts == nil {
posts = []model.Post{}
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
common.Ok(c, model.PostListResult{ common.Ok(c, model.PostListResult{
Items: posts, Items: posts,
Total: total, Total: total,
Page: p.Page, Page: page,
TotalPages: p.TotalPages(total), TotalPages: common.PageCount(total, pageSize),
}) })
} }
@ -358,27 +334,14 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见 // 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved { if post.Status != model.PostStatusApproved {
var uid uint uid, role, _ := common.GetGinUser(c)
var role string if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
common.Error(c, http.StatusNotFound, "帖子不存在") common.Error(c, http.StatusNotFound, "帖子不存在")
return return
} }
} }
// 处理 shortcode[zone:type:params] → HTML 占位符 ctrl.applyShortcode(post)
if ctrl.shortcodeSvc != nil {
result := ctrl.shortcodeSvc.Process(post.Body)
post.Body = result.ProcessedBody
}
common.Ok(c, post) common.Ok(c, post)
} }
@ -390,8 +353,7 @@ var allowedImageExt = map[string]bool{
// UploadImage 上传帖子图片需登录multipart/form-data // UploadImage 上传帖子图片需登录multipart/form-data
func (ctrl *PostController) UploadImage(c *gin.Context) { func (ctrl *PostController) UploadImage(c *gin.Context) {
uidObj, _ := c.Get("uid") uid, _, ok := common.GetGinUser(c)
uid, ok := uidObj.(uint)
if !ok { if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录") common.Error(c, http.StatusUnauthorized, "请先登录")
return return
@ -430,7 +392,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext) filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
savePath := filepath.Join(storageDir, filename) 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, "保存图片失败") common.Error(c, http.StatusInternalServerError, "保存图片失败")
return return
} }
@ -439,26 +401,11 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
common.VditorUploadOk(c, map[string]string{header.Filename: url}) common.VditorUploadOk(c, map[string]string{header.Filename: url})
} }
// saveUploadedFile 将 multipart.File 写入目标路径 // applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
func saveUploadedFile(file multipart.File, dst string) error { func (ctrl *PostController) applyShortcode(post *model.Post) {
out, err := os.Create(dst) if ctrl.shortcodeSvc != nil {
if err != nil { result := ctrl.shortcodeSvc.Process(post.Body)
return err 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
} }

View 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",
}))
}

View File

@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/config"
@ -44,12 +45,16 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
} }
if !constantTimeEq(cookieToken, headerToken) { 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{ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败", "success": false, "message": "CSRF 验证失败",
}) })
return return
} }
log.Printf("[CSRF] OK | path=%s | token(len=%d)=%q", c.Request.URL.Path, len(cookieToken), cookieToken)
c.Set(csrfMetaName, cookieToken) c.Set(csrfMetaName, cookieToken)
c.Next() c.Next()
} }

View File

@ -20,4 +20,37 @@ type CheckEmailRequest struct {
Email string `json:"email" binding:"required,email"` 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"`
}

View File

@ -34,45 +34,3 @@ const (
PostStatusRejected = "rejected" PostStatusRejected = "rejected"
PostStatusLocked = "locked" 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"`
}

View File

@ -45,35 +45,8 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
return &post, nil return &post, nil
} }
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题 // buildQuery 构建帖子查询公共部分(联表 + 未删除 + 关键词 + 可选状态过滤
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) { 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").
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) {
query := r.db.Table("posts"). query := r.db.Table("posts").
Select("posts.*, users.username as author_name"). Select("posts.*, users.username as author_name").
Joins("LEFT JOIN users ON users.uid = posts.user_id"). 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 != "" { if status != "" {
query = query.Where("posts.status = ?", 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 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
@ -113,5 +109,12 @@ func (r *PostRepo) SoftDelete(id uint) error {
// Restore 恢复软删除 // Restore 恢复软删除
func (r *PostRepo) Restore(id uint) error { 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
} }

View File

@ -17,6 +17,7 @@ type dependencies struct {
settingsCtrl *controller.SettingsController settingsCtrl *controller.SettingsController
messageCtrl *controller.MessageController messageCtrl *controller.MessageController
postCtrl *controller.PostController postCtrl *controller.PostController
spaceCtrl *controller.SpaceController
adminPostCtrl *adminCtrl.AdminPostController adminPostCtrl *adminCtrl.AdminPostController
adminCtrl *adminCtrl.AdminController adminCtrl *adminCtrl.AdminController
auditCtrl *adminCtrl.AuditController auditCtrl *adminCtrl.AuditController

View File

@ -36,9 +36,14 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
postCtrl := controller.NewPostController(postService, shortcodeSvc) postCtrl := controller.NewPostController(postService, shortcodeSvc)
adminPostCtrl := adminCtrl.NewAdminPostController(postService) adminPostCtrl := adminCtrl.NewAdminPostController(postService)
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
return &dependencies{ return &dependencies{
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl, authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl, messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
adminPostCtrl: adminPostCtrl,
adminCtrl: adminController, auditCtrl: auditController, adminCtrl: adminController, auditCtrl: auditController,
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl, siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
} }

View File

@ -34,6 +34,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage) pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
pages.GET("/messages", d.messageCtrl.MessagesPage) 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", d.postCtrl.ListPage)
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage) pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)

View File

@ -144,18 +144,29 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
return s.findPostWithAuthor(id) return s.findPostWithAuthor(id)
} }
// List 公开帖子列表(仅 approved // listPageable 分页查询公共逻辑:参数规范化 + nil 转空切片
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) { 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 := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination() 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 管理后台帖子列表(全状态) // ListAdmin 管理后台帖子列表(全状态)
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) { func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize} return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
p.DefaultPagination() return s.repo.FindAdminPageable(keyword, status, offset, limit)
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize) })
} }
// Update 编辑帖子(权限在 controller 层检查) // Update 编辑帖子(权限在 controller 层检查)
@ -253,5 +264,17 @@ func (s *PostService) Unlock(postID uint) error {
// Restore 恢复软删除 // Restore 恢复软删除
func (s *PostService) Restore(postID uint) error { 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)
} }

View File

@ -42,3 +42,13 @@ type postStore interface {
SoftDelete(id uint) error SoftDelete(id uint) error
Restore(id uint) error Restore(id uint) error
} }
// spaceUserStore SpaceService 所需的最小用户仓储接口ISP1 个方法)
type spaceUserStore interface {
FindByID(id uint) (*model.User, error)
}
// spacePostStore SpaceService 所需的最小帖子仓储接口ISP1 个方法)
type spacePostStore interface {
FindByUserID(userID uint, offset, limit int) ([]model.Post, int64, error)
}

View 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)
}

View File

@ -4,6 +4,14 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}} {{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
{{if .OgTitle}}
<meta property="og:title" content="{{.OgTitle}}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="MetaLab">
{{if .OgDescription}}<meta property="og:description" content="{{.OgDescription}}">{{end}}
{{if .OgImage}}<meta property="og:image" content="{{.OgImage}}">{{end}}
{{if .OgURL}}<meta property="og:url" content="{{.OgURL}}">{{end}}
{{end}}
<title>{{.Title}} - MetaLab</title> <title>{{.Title}} - MetaLab</title>
<link rel="stylesheet" href="/static/css/common.css"> <link rel="stylesheet" href="/static/css/common.css">
{{if .ExtraCSS}} {{if .ExtraCSS}}

View File

@ -14,7 +14,8 @@
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span> <span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
</a> </a>
</li> </li>
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li> <li><a href="/space" class="nav-user">{{.Username}}</a></li>
<li><a href="/settings/profile" class="nav-settings">设置</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li> <li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{else}} {{else}}
<li><a href="/auth/login" class="nav-login">登录</a></li> <li><a href="/auth/login" class="nav-login">登录</a></li>

View File

@ -29,7 +29,7 @@
<a href="/posts/{{.ID}}">{{.Title}}</a> <a href="/posts/{{.ID}}">{{.Title}}</a>
</h2> </h2>
<div class="post-card-meta"> <div class="post-card-meta">
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</span> <span class="post-author">{{if .AuthorName}}{{.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span> <span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div> </div>
<p class="post-card-summary">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p> <p class="post-card-summary">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>

View File

@ -24,6 +24,30 @@
<div class="editor-pane"> <div class="editor-pane">
<div id="vditor"></div> <div id="vditor"></div>
</div> </div>
<aside class="editor-sidebar">
<div class="editor-sidebar-section">
<h4>快捷语法</h4>
<ul class="tips-list">
<li><code>#</code> <code>##</code> <code>###</code> 标题</li>
<li><code>**粗体**</code> <code>*斜体*</code></li>
<li><code>`行内代码`</code></li>
<li><code>```</code> 代码块</li>
<li><code>&gt;</code> 引用</li>
<li><code>-</code> 无序列表</li>
<li><code>[文字](链接)</code></li>
<li><code>![图片](链接)</code></li>
</ul>
</div>
<div class="editor-sidebar-section">
<h4>发布提示</h4>
<ul class="tips-list">
<li>标题控制在 50 字以内</li>
<li>正文清晰分段,便于阅读</li>
<li>代码使用代码块包裹</li>
<li>Ctrl+S 快速保存草稿</li>
</ul>
</div>
</aside>
</div> </div>
{{/* 状态栏 */}} {{/* 状态栏 */}}

View File

@ -8,7 +8,7 @@
<header class="post-detail-header"> <header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1> <h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta"> <div class="post-detail-meta">
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}({{.Post.UserID}}){{else}}UID{{.Post.UserID}}{{end}}</span> <span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span> <span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if ne .Post.Status "approved"}} {{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span> <span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
@ -57,6 +57,18 @@
cdn: '/static/vditor', cdn: '/static/vditor',
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' }, theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true }, 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() 渲染 MDshortcode 占位 div 原样保留 // Vditor.preview() 渲染 MDshortcode 占位 div 原样保留

View File

@ -0,0 +1,93 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
{{if .Error}}
<div class="space-error">
<div class="container">
<h1>{{.Error}}</h1>
<a href="/" class="btn btn-primary">返回首页</a>
</div>
</div>
{{else}}
<div class="space-page">
<!-- 用户信息头部 -->
<div class="space-header">
<div class="container space-header-inner">
<div class="space-avatar">
{{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}">
{{else}}
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
{{end}}
</div>
<div class="space-info">
<h1 class="space-username">{{.SpaceUser.Username}}</h1>
<p class="space-bio">{{if .SpaceUser.Bio}}{{.SpaceUser.Bio}}{{else}}&nbsp;{{end}}</p>
<div class="space-meta">
<span class="space-uid">UID: {{.SpaceUser.ID}}</span>
<span class="space-divider">·</span>
<span class="space-joined">加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="container space-content">
<!-- 统计栏 -->
<div class="space-stats">
<div class="space-stat-item">
<span class="space-stat-num">{{.Total}}</span>
<span class="space-stat-label">稿件</span>
</div>
</div>
<!-- 稿件列表 -->
<div class="space-section">
<h2 class="space-section-title">稿件</h2>
{{if eq (len .Posts) 0}}
<div class="empty-state">暂无稿件</div>
{{else}}
<div class="space-posts-list">
{{range .Posts}}
<article class="space-post-card">
<div class="space-post-main">
<h3 class="space-post-title">
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h3>
<p class="space-post-excerpt">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
<div class="space-post-meta">
<span class="space-post-status">已发布</span>
<span class="space-post-time">{{.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</article>
{{end}}
</div>
{{end}}
<!-- 分页 -->
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页 (共 {{.Total}} 条)</span>
{{if lt .Page .TotalPages}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script>
</body>
</html>

View File

@ -26,13 +26,23 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {} html {
height: 100%;
}
body { body {
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
line-height: 1.6; line-height: 1.6;
overflow-x: hidden; overflow-x: hidden;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 主内容区撑满剩余空间,实现 sticky footer */
body > .container {
flex: 1;
} }
a { a {
@ -126,6 +136,11 @@ header {
font-weight: 600 !important; font-weight: 600 !important;
} }
.nav-settings {
font-size: .9rem;
color: var(--color-secondary) !important;
}
.nav-logout { .nav-logout {
color: var(--color-secondary) !important; color: var(--color-secondary) !important;
} }
@ -203,6 +218,7 @@ footer {
background: var(--color-primary); background: var(--color-primary);
color: #bdc3c7; color: #bdc3c7;
padding: .8rem 0; padding: .8rem 0;
flex-shrink: 0;
} }
.footer-content { .footer-content {

View File

@ -297,7 +297,11 @@
height: calc(100vh - 56px); height: calc(100vh - 56px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: visible;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 28px;
} }
.editor-form { .editor-form {
@ -307,7 +311,7 @@
} }
.editor-header { .editor-header {
padding: 16px 24px 12px; padding: 16px 0 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
background: #fff; background: #fff;
flex-shrink: 0; flex-shrink: 0;
@ -330,21 +334,73 @@
.editor-main { .editor-main {
flex: 1; flex: 1;
display: flex; display: flex;
overflow: hidden; overflow: visible;
min-height: 0; min-height: 0;
} }
.editor-pane { .editor-pane {
flex: 1; flex: 1;
overflow: hidden; overflow: visible;
min-width: 0; 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 { .editor-statusbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 6px 20px; padding: 6px 0;
background: #fafafa; background: #fafafa;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
font-size: 12px; font-size: 12px;
@ -364,32 +420,19 @@
z-index: 9999; z-index: 9999;
height: 100vh; height: 100vh;
background: #fff; background: #fff;
max-width: none;
} }
/* ========== Vditor 编辑器容器 ========== */ /* ========== Vditor 编辑器容器 ========== */
/* Vditor 自动生成工具栏、编辑区、状态栏,仅做外层布局微调 */ /* 仅提供高度约束,不干预 Vditor 内部布局和弹窗 */
#vditor { #vditor {
flex: 1; flex: 1;
overflow: hidden;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; 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 预览/渲染区域 — 详情页 */ /* Vditor 预览/渲染区域 — 详情页 */
.post-detail-body .vditor-reset { .post-detail-body .vditor-reset {
font-size: 16px; font-size: 16px;

View File

@ -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;
}
}

View File

@ -20,7 +20,7 @@
}; };
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ---- // ---- 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 ---- // ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header // Monkey-patch XMLHttpRequest and fetch to inject CSRF header
@ -31,7 +31,10 @@
var refreshPromise = null; var refreshPromise = null;
var pendingRetries = []; var pendingRetries = [];
// 优先从 cookie 读取 CSRF token与服务端验证源一致meta 标签作为后备
function resolveCSRFToken() { 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"]'); var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : ''; return meta ? meta.getAttribute('content') : '';
} }
@ -92,7 +95,11 @@
} }
// 401 interception for XHR // 401 interception for XHR
// 保存原始 onreadystatechange 后立即清除,防止原生属性重复触发
// 否则浏览器原生 onreadystatechange 和 addEventListener 监听器会各触发一次
// 导致 Vditor 图片上传等场景下图片被插入两次
var origOnReady = xhr.onreadystatechange; var origOnReady = xhr.onreadystatechange;
xhr.onreadystatechange = null;
xhr.addEventListener('readystatechange', function () { xhr.addEventListener('readystatechange', function () {
if (xhr.readyState === 4 && xhr.status === 401 && if (xhr.readyState === 4 && xhr.status === 401 &&
xhr._url !== '/api/auth/refresh' && xhr._url !== '/api/auth/refresh' &&

View File

@ -17,9 +17,10 @@ let draftTimer = null
let submitting = false let submitting = false
// ---- CSRF Token ---- // ---- CSRF Token ----
// 直接从 cookie 读取 mlb_csrfhttpOnly=false确保与服务端验证时读取的值一致
function getCsrfToken() { function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]') var match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/)
return meta ? meta.getAttribute('content') : '' return match ? match[1] : ''
} }
// ---- 初始化 ---- // ---- 初始化 ----
@ -63,6 +64,10 @@ document.addEventListener('DOMContentLoaded', () => {
'outline', 'preview', 'devtools', 'outline', 'preview', 'devtools',
], ],
// Vditor 3.11.2 中 highlightToolbarWYSIWYG 直接调用此回调
// 不提供会导致 TypeError: not a function所有弹窗代码块语言等都不可用
customWysiwygToolbar(type, popover) {},
// 计数器 // 计数器
counter: { counter: {
enable: true, enable: true,
@ -94,27 +99,38 @@ document.addEventListener('DOMContentLoaded', () => {
// 大纲 // 大纲
outline: { outline: {
enable: true, enable: false,
position: 'right', position: 'right',
}, },
// 预览配置 // 预览配置
// 注意WYSIWYG 编辑模式下代码块不做语法高亮Vditor 3.11.2 源码中
// highlightRender 会跳过 .vditor-wysiwyg__pre仅在预览模式/详情页渲染)
// langs 确保语言选择下拉始终可用,不受 hljs 异步加载时机影响
preview: { preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' }, 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 }, markdown: { codeBlockPreview: true },
}, },
// 图片上传 // 图片上传
// CSRF token 由 common.js 的全局 XHR monkey-patch 自动注入,此处无需重复设置
// 重复设置会导致浏览器将 header 合并为 "TOKEN, TOKEN" 从而验证失败
upload: { upload: {
url: '/api/posts/upload-image', url: '/api/posts/upload-image',
fieldName: 'file', fieldName: 'file',
max: 5 * 1024 * 1024, max: 5 * 1024 * 1024,
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp', accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
// setHeaders 为函数,每次上传前重新读取 CSRF token
setHeaders() {
return { 'X-CSRF-Token': getCsrfToken() }
},
}, },
// 初始内容 // 初始内容