文件管理增加数据库表。
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user