From f1a6c5abaa219bf64fd22ec5e1364629577a187b Mon Sep 17 00:00:00 2001 From: Zhang Chao Date: Tue, 18 Aug 2026 17:29:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=95=B0=E6=8D=AE=E5=BA=93=E8=A1=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/database.go | 1 + handlers/backend/auth.go | 59 ++++++++------- handlers/backend/upload.go | 151 ++++++++++++++++++++++--------------- models/models.go | 22 ++++++ routers/router.go | 8 +- static/admin/app.js | 14 ++-- 6 files changed, 157 insertions(+), 98 deletions(-) diff --git a/database/database.go b/database/database.go index d3bb7d5..4fbaf19 100644 --- a/database/database.go +++ b/database/database.go @@ -33,6 +33,7 @@ func Init(cfg *config.DatabaseConfig) error { &models.Comment{}, &models.Page{}, &models.Option{}, + &models.File{}, ) if err != nil { return err diff --git a/handlers/backend/auth.go b/handlers/backend/auth.go index b11f2b3..b81a57f 100644 --- a/handlers/backend/auth.go +++ b/handlers/backend/auth.go @@ -30,35 +30,40 @@ type AdminMenuGroup struct { Items []AdminMenuItem `json:"items"` } +var adminMenus = []AdminMenuGroup{ + {Group: "", Items: []AdminMenuItem{ + {Key: "dashboard", Name: "仪表盘"}, + }}, + {Group: "内容管理", Items: []AdminMenuItem{ + {Key: "posts", Name: "文章管理"}, + {Key: "categories", Name: "分类管理"}, + {Key: "tags", Name: "标签管理"}, + {Key: "pages", Name: "页面管理"}, + {Key: "comments", Name: "评论管理"}, + }}, + {Group: "系统管理", Items: []AdminMenuItem{ + {Key: "users", Name: "用户管理"}, + {Key: "files", Name: "文件管理"}, + {Key: "themes", Name: "主题管理"}, + {Key: "settings", Name: "基础设置"}, + }}, +} +var userMenus = []AdminMenuGroup{ + {Group: "", Items: []AdminMenuItem{ + {Key: "dashboard", Name: "仪表盘"}, + {Key: "posts", Name: "文章管理"}, + {Key: "files", Name: "文件管理"}, + {Key: "comments", Name: "评论管理"}, + }}, +} + // GetMenusByRole 根据角色返回后台菜单 func GetMenusByRole(role string) []AdminMenuGroup { - if role == "admin" { - return []AdminMenuGroup{ - {Group: "", Items: []AdminMenuItem{ - {Key: "dashboard", Name: "仪表盘"}, - }}, - {Group: "内容管理", Items: []AdminMenuItem{ - {Key: "posts", Name: "文章管理"}, - {Key: "categories", Name: "分类管理"}, - {Key: "tags", Name: "标签管理"}, - {Key: "pages", Name: "页面管理"}, - {Key: "comments", Name: "评论管理"}, - }}, - {Group: "系统管理", Items: []AdminMenuItem{ - {Key: "users", Name: "用户管理"}, - {Key: "files", Name: "文件管理"}, - {Key: "themes", Name: "主题管理"}, - {Key: "settings", Name: "基础设置"}, - }}, - } - } - // 普通用户:仪表盘 + 文章管理 + 评论管理 - return []AdminMenuGroup{ - {Group: "", Items: []AdminMenuItem{ - {Key: "dashboard", Name: "仪表盘"}, - {Key: "posts", Name: "文章管理"}, - {Key: "comments", Name: "评论管理"}, - }}, + switch role { + case "admin": + return adminMenus + default: + return userMenus } } diff --git a/handlers/backend/upload.go b/handlers/backend/upload.go index c2b6a5b..e51b471 100644 --- a/handlers/backend/upload.go +++ b/handlers/backend/upload.go @@ -6,15 +6,17 @@ import ( "net/http" "os" "path/filepath" - "sort" "strings" "time" + "goblog/database" + "goblog/models" + "github.com/gin-gonic/gin" ) const ( - // uploadDir 上传文件保存目录 + // uploadDir 上传文件保存根目录 uploadDir = "./static/uploads" // uploadURLPrefix 上传文件对外访问的 URL 前缀 uploadURLPrefix = "/static/uploads/" @@ -39,14 +41,6 @@ var allowedImageMIMEs = map[string]bool{ "image/webp": true, } -// UploadedFile 上传文件信息 -type UploadedFile struct { - Name string `json:"name"` - URL string `json:"url"` - Size int64 `json:"size"` - ModTime string `json:"mod_time"` -} - // sanitizeBaseName 清理文件名(去除扩展名和不安全字符) func sanitizeBaseName(filename string) string { base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) @@ -70,7 +64,7 @@ func sanitizeBaseName(filename string) string { return res } -// UploadImage 上传图片(校验格式与大小) +// UploadImage 上传图片(校验格式与大小,写入 files 表,按年月目录存储) func UploadImage(c *gin.Context) { file, err := c.FormFile("file") if err != nil { @@ -115,15 +109,19 @@ func UploadImage(c *gin.Context) { return } - // 确保上传目录存在 - if err := os.MkdirAll(uploadDir, 0o755); err != nil { + // 按年月生成子目录:uploads/202608/ + now := time.Now() + yearMonth := now.Format("200601") + subDir := filepath.Join(uploadDir, yearMonth) + + if err := os.MkdirAll(subDir, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"}) return } // 生成唯一文件名:时间戳_原始名.扩展名 - filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext) - dstPath := filepath.Join(uploadDir, filename) + filename := fmt.Sprintf("%d_%s%s", now.UnixNano(), sanitizeBaseName(file.Filename), ext) + dstPath := filepath.Join(subDir, filename) dst, err := os.Create(dstPath) if err != nil { @@ -137,79 +135,110 @@ func UploadImage(c *gin.Context) { return } + // 获取当前用户 ID + userID, _ := c.Get("userID") + + // 相对路径(用于 URL 拼接和数据库存储) + relPath := filepath.Join(yearMonth, filename) + + // 写入 files 表 + fileRecord := models.File{ + UserID: userID.(uint), + FileName: filename, + OrigName: file.Filename, + FilePath: relPath, + FileSize: file.Size, + MimeType: contentType, + } + if err := database.DB.Create(&fileRecord).Error; err != nil { + // 文件已保存但数据库写入失败,记录日志但不阻断返回 + fmt.Printf("写入文件记录失败: %v\n", err) + } + c.JSON(http.StatusOK, gin.H{ - "url": uploadURLPrefix + filename, + "url": uploadURLPrefix + strings.ReplaceAll(relPath, "\\", "/"), "name": filename, "size": file.Size, }) } -// ListUploads 列出已上传的图片 +// ListUploads 列出已上传的图片(从数据库读取,管理员看全部,普通用户看自己的) func ListUploads(c *gin.Context) { - if err := os.MkdirAll(uploadDir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"}) + userID, _ := c.Get("userID") + role, _ := c.Get("role") + + var files []models.File + query := database.DB.Order("created_at DESC") + + if role != "admin" { + query = query.Where("user_id = ?", userID) + } + + if err := query.Find(&files).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败"}) return } - entries, err := os.ReadDir(uploadDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"}) - return + // 转换为前端需要的格式 + type FileItem struct { + ID uint `json:"id"` + Name string `json:"name"` + OrigName string `json:"orig_name"` + URL string `json:"url"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + UserID uint `json:"user_id"` + CreatedAt string `json:"created_at"` } - files := make([]UploadedFile, 0, len(entries)) - for _, e := range entries { - if e.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(e.Name())) - if !allowedImageExts[ext] { - continue - } - info, err := e.Info() - if err != nil { - continue - } - files = append(files, UploadedFile{ - Name: e.Name(), - URL: uploadURLPrefix + e.Name(), - Size: info.Size(), - ModTime: info.ModTime().Format("2006-01-02 15:04:05"), + items := make([]FileItem, 0, len(files)) + for _, f := range files { + items = append(items, FileItem{ + ID: f.ID, + Name: f.FileName, + OrigName: f.OrigName, + URL: uploadURLPrefix + strings.ReplaceAll(f.FilePath, "\\", "/"), + Size: f.FileSize, + MimeType: f.MimeType, + UserID: f.UserID, + CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), }) } - // 按修改时间倒序(最新的在前) - sort.Slice(files, func(i, j int) bool { - return files[i].ModTime > files[j].ModTime - }) - - c.JSON(http.StatusOK, gin.H{"data": files}) + c.JSON(http.StatusOK, gin.H{"data": items}) } -// DeleteUpload 删除已上传的图片 +// DeleteUpload 删除已上传的图片(同时删除数据库记录和物理文件) func DeleteUpload(c *gin.Context) { - name := c.Param("name") - - // 防止路径穿越 - if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { - c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"}) + fileID := c.Param("id") + if fileID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少文件ID"}) return } - ext := strings.ToLower(filepath.Ext(name)) - if !allowedImageExts[ext] { - c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"}) + var file models.File + if err := database.DB.First(&file, fileID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文件记录不存在"}) return } - path := filepath.Join(uploadDir, name) - if _, err := os.Stat(path); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) + // 权限校验:管理员可删所有,普通用户只能删自己的 + userID, _ := c.Get("userID") + role, _ := c.Get("role") + if role != "admin" && file.UserID != userID.(uint) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件"}) return } - if err := os.Remove(path); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"}) + // 删除物理文件 + physPath := filepath.Join(uploadDir, file.FilePath) + if _, err := os.Stat(physPath); err == nil { + os.Remove(physPath) + } + + // 删除数据库记录 + if err := database.DB.Delete(&file).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败"}) return } diff --git a/models/models.go b/models/models.go index 44eca2d..72bdf31 100644 --- a/models/models.go +++ b/models/models.go @@ -262,3 +262,25 @@ type Page struct { func (Page) TableName() string { return "pages" } + +// ==================== 文件模型 ==================== + +// File 上传文件记录 +type File struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + UserID uint `gorm:"index;not null" json:"user_id"` // 上传者 + User User `gorm:"foreignKey:UserID" json:"user"` // 上传者信息 + FileName string `gorm:"size:255;not null" json:"file_name"` // 存储文件名 + OrigName string `gorm:"size:255" json:"orig_name"` // 原始文件名 + FilePath string `gorm:"size:500;not null" json:"file_path"` // 相对存储路径(含年月目录) + FileSize int64 `gorm:"default:0" json:"file_size"` // 文件大小(字节) + MimeType string `gorm:"size:100" json:"mime_type"` // MIME 类型 +} + +// TableName 指定表名 +func (File) TableName() string { + return "files" +} diff --git a/routers/router.go b/routers/router.go index b875173..4608dac 100644 --- a/routers/router.go +++ b/routers/router.go @@ -117,8 +117,10 @@ func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) { // Markdown 预览 auth.POST("/markdown/preview", backend.PreviewMarkdown) - // 文件上传 + // 文件上传与管理 auth.POST("/upload", backend.UploadImage) + auth.GET("/uploads", backend.ListUploads) + auth.DELETE("/uploads/:id", backend.DeleteUpload) // 我的评论管理(管理自己文章下的评论) auth.GET("/my/comments", backend.GetComments) @@ -174,8 +176,8 @@ func setupAdminAPIRoutes(api *gin.RouterGroup) { admin.GET("/settings", backend.GetSettings) admin.PUT("/settings", backend.UpdateSettings) - // 文件管理 + // 文件管理(管理员可看全部) admin.POST("/upload", backend.UploadImage) admin.GET("/uploads", backend.ListUploads) - admin.DELETE("/uploads/:name", backend.DeleteUpload) + admin.DELETE("/uploads/:id", backend.DeleteUpload) } diff --git a/static/admin/app.js b/static/admin/app.js index 7f2195b..13c134d 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -1559,7 +1559,7 @@ function removeCover() { // 加载已上传文件列表 async function loadFiles() { try { - const res = await fetch(`${API_BASE}/admin/uploads`, { headers: getHeaders() }); + const res = await fetch(`${API_BASE}/uploads`, { headers: getHeaders() }); const data = await res.json(); const grid = document.getElementById('file-grid'); @@ -1570,14 +1570,14 @@ async function loadFiles() { grid.innerHTML = data.data.map(f => `
-
${f.name}
+
${f.orig_name || f.name}
-
${f.name}
-
${formatFileSize(f.size)} · ${f.mod_time}
+
${f.orig_name || f.name}
+
${formatFileSize(f.size)} · ${f.created_at}
- +
`).join(''); @@ -1609,11 +1609,11 @@ async function handleFileUpload(e) { } // 删除已上传文件 -async function deleteFile(name) { +async function deleteFile(id) { if (!confirm('确定要删除这个文件吗?')) return; try { - const res = await fetch(`${API_BASE}/admin/uploads/${encodeURIComponent(name)}`, { + const res = await fetch(`${API_BASE}/uploads/${id}`, { method: 'DELETE', headers: getHeaders() });