文件管理增加数据库表。

This commit is contained in:
2026-08-18 17:29:06 +08:00
parent f1780de632
commit f1a6c5abaa
6 changed files with 157 additions and 98 deletions
+1
View File
@@ -33,6 +33,7 @@ func Init(cfg *config.DatabaseConfig) error {
&models.Comment{},
&models.Page{},
&models.Option{},
&models.File{},
)
if err != nil {
return err
+32 -27
View File
@@ -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
}
}
+90 -61
View File
@@ -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
}
+22
View File
@@ -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"
}
+5 -3
View File
@@ -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)
}
+7 -7
View File
@@ -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 => `
<div class="file-card">
<div class="file-thumb"><img src="${f.url}" alt="${f.name}" loading="lazy"></div>
<div class="file-thumb"><img src="${f.url}" alt="${f.orig_name || f.name}" loading="lazy"></div>
<div class="file-info">
<div class="file-name" title="${f.name}">${f.name}</div>
<div class="file-meta">${formatFileSize(f.size)} · ${f.mod_time}</div>
<div class="file-name" title="${f.orig_name || f.name}">${f.orig_name || f.name}</div>
<div class="file-meta">${formatFileSize(f.size)} · ${f.created_at}</div>
</div>
<div class="file-actions">
<button class="btn-small btn-secondary" onclick="copyFileURL('${f.url}')">复制链接</button>
<button class="btn-small btn-danger" onclick="deleteFile('${f.name}')">删除</button>
<button class="btn-small btn-danger" onclick="deleteFile(${f.id})">删除</button>
</div>
</div>
`).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()
});