feat: 统一文件上传接口 + 模板图片展示 + 导航页 + 侧边栏调整
This commit is contained in:
@@ -55,6 +55,62 @@ var tableLabels = map[string]string{
|
||||
"music_categories": "音乐分类",
|
||||
}
|
||||
|
||||
// tablesWithSort 拥有 sort 列的表(用于排序)
|
||||
var tablesWithSort = map[string]bool{
|
||||
"enterprise_services": true,
|
||||
"enterprise_team": true,
|
||||
"shop_products": true,
|
||||
"shop_categories": true,
|
||||
"food_restaurants": true,
|
||||
"food_categories": true,
|
||||
"food_menu_items": true,
|
||||
"food_menu_categories": true,
|
||||
"game_games": true,
|
||||
"game_categories": true,
|
||||
"app_apps": true,
|
||||
"app_categories": true,
|
||||
"music_songs": true,
|
||||
"music_categories": true,
|
||||
}
|
||||
|
||||
// tablesWithName 拥有 name 列的表(用于关键词搜索)
|
||||
var tablesWithName = map[string]bool{
|
||||
"enterprise_company": true,
|
||||
"enterprise_team": true,
|
||||
"enterprise_services": false, // 有 title 无 name
|
||||
"enterprise_news": false,
|
||||
"shop_products": true,
|
||||
"shop_categories": true,
|
||||
"food_restaurants": true,
|
||||
"food_categories": true,
|
||||
"food_menu_items": true,
|
||||
"food_menu_categories": true,
|
||||
"game_games": true,
|
||||
"game_categories": true,
|
||||
"app_apps": true,
|
||||
"app_categories": true,
|
||||
"music_songs": false, // 有 title 无 name
|
||||
"music_categories": true,
|
||||
}
|
||||
|
||||
// tablesWithTitle 拥有 title 列的表(用于关键词搜索)
|
||||
var tablesWithTitle = map[string]bool{
|
||||
"enterprise_news": true,
|
||||
"enterprise_services": true,
|
||||
"music_songs": true,
|
||||
}
|
||||
|
||||
// tablesWithStatus 拥有 status 列的表(用于状态过滤)
|
||||
var tablesWithStatus = map[string]bool{
|
||||
"enterprise_news": true,
|
||||
"shop_products": true,
|
||||
"food_restaurants": true,
|
||||
"food_menu_items": true,
|
||||
"game_games": true,
|
||||
"app_apps": true,
|
||||
"music_songs": true,
|
||||
}
|
||||
|
||||
// DashboardHandler 管理后台仪表盘处理器(通用 CRUD + 统计)
|
||||
type DashboardHandler struct {
|
||||
db *gorm.DB
|
||||
@@ -174,21 +230,36 @@ func (h *DashboardHandler) ListItems(c *gin.Context) {
|
||||
|
||||
query := h.db.Model(modelType)
|
||||
|
||||
// 关键词搜索(按 name 或 title 字段)
|
||||
// 关键词搜索(根据表实际拥有的列动态构建条件)
|
||||
if keyword != "" {
|
||||
query = query.Where("name LIKE ? OR title LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
hasName := tablesWithName[table]
|
||||
hasTitle := tablesWithTitle[table]
|
||||
switch {
|
||||
case hasName && hasTitle:
|
||||
query = query.Where("name LIKE ? OR title LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
case hasName:
|
||||
query = query.Where("name LIKE ?", "%"+keyword+"%")
|
||||
case hasTitle:
|
||||
query = query.Where("title LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if status != "" {
|
||||
// 状态过滤(仅对拥有 status 列的表生效)
|
||||
if status != "" && tablesWithStatus[table] {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 排序:有 sort 列的表按 sort ASC, id DESC;否则按 id DESC
|
||||
orderClause := "id DESC"
|
||||
if tablesWithSort[table] {
|
||||
orderClause = "sort ASC, id DESC"
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
if err := query.Order("sort ASC, id DESC").Offset(offset).Limit(pageSize).Find(&results).Error; err != nil {
|
||||
if err := query.Order(orderClause).Offset(offset).Limit(pageSize).Find(&results).Error; err != nil {
|
||||
FailInternal(c, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
|
||||
return &UploadHandler{uploadService: uploadService}
|
||||
}
|
||||
|
||||
// Upload 文件上传 POST /api/v1/upload
|
||||
// Upload 文件上传 POST /api/v1/upload?module=enterprise
|
||||
// module 参数指定上传到哪个模块(enterprise/shop/food/game/app/music/common)
|
||||
func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
module := c.DefaultQuery("module", "common")
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
FailBadRequest(c, "请选择要上传的文件")
|
||||
@@ -31,7 +34,7 @@ func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
url, err := h.uploadService.Upload(file.Filename, file.Header.Get("Content-Type"), file.Size, src)
|
||||
url, err := h.uploadService.Upload(module, file.Filename, file.Header.Get("Content-Type"), file.Size, src)
|
||||
if err != nil {
|
||||
FailBadRequest(c, err.Error())
|
||||
return
|
||||
@@ -39,3 +42,42 @@ func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
|
||||
Success(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// ListFiles 文件列表 GET /api/v1/upload/list?module=enterprise
|
||||
func (h *UploadHandler) ListFiles(c *gin.Context) {
|
||||
module := c.DefaultQuery("module", "common")
|
||||
|
||||
files, err := h.uploadService.ListFiles(module)
|
||||
if err != nil {
|
||||
FailInternal(c, "获取文件列表失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"list": files,
|
||||
"total": len(files),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件 DELETE /api/v1/upload?module=enterprise&filename=xxx.jpg
|
||||
func (h *UploadHandler) DeleteFile(c *gin.Context) {
|
||||
module := c.DefaultQuery("module", "common")
|
||||
filename := c.Query("filename")
|
||||
if filename == "" {
|
||||
FailBadRequest(c, "文件名不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.uploadService.DeleteFile(module, filename); err != nil {
|
||||
FailBadRequest(c, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMsg(c, "删除成功")
|
||||
}
|
||||
|
||||
// GetModules 获取支持的模块列表 GET /api/v1/upload/modules
|
||||
func (h *UploadHandler) GetModules(c *gin.Context) {
|
||||
modules := h.uploadService.GetModules()
|
||||
Success(c, gin.H{"modules": modules})
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ func Setup(
|
||||
})
|
||||
})
|
||||
|
||||
// 静态文件服务:上传的文件可通过 /uploads/* 访问
|
||||
r.Static("/uploads", cfg.Upload.Path)
|
||||
|
||||
// API v1 路由组
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
@@ -155,8 +158,11 @@ func Setup(
|
||||
user.PUT("/profile", userH.UpdateProfile)
|
||||
user.PUT("/password", userH.ChangePassword)
|
||||
}
|
||||
// 文件上传
|
||||
// 文件上传(统一上传接口,支持按模块分类)
|
||||
authed.POST("/upload", uploadH.Upload)
|
||||
authed.GET("/upload/list", uploadH.ListFiles)
|
||||
authed.GET("/upload/modules", uploadH.GetModules)
|
||||
authed.DELETE("/upload", uploadH.DeleteFile)
|
||||
}
|
||||
|
||||
// 管理端路由(需要登录 + admin 角色)
|
||||
|
||||
@@ -3,7 +3,10 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gotest/internal/config"
|
||||
@@ -21,9 +24,21 @@ func NewUploadService(s storage.Storage, cfg *config.Config) *UploadService {
|
||||
return &UploadService{storage: s, cfg: cfg}
|
||||
}
|
||||
|
||||
// allowedModules 允许的模块列表
|
||||
var allowedModules = map[string]string{
|
||||
"enterprise": "enterprise",
|
||||
"shop": "shop",
|
||||
"food": "food",
|
||||
"game": "game",
|
||||
"app": "app",
|
||||
"music": "music",
|
||||
"common": "common",
|
||||
}
|
||||
|
||||
// Upload 上传文件,返回访问 URL
|
||||
// filename 原始文件名(用于提取扩展名),contentType MIME 类型,fileSize 文件大小(字节),reader 文件内容流
|
||||
func (s *UploadService) Upload(filename string, contentType string, fileSize int64, reader io.Reader) (string, error) {
|
||||
// module 指定上传到的模块(enterprise/shop/food/game/app/music/common)
|
||||
// filename 原始文件名,contentType MIME 类型,fileSize 文件大小,reader 文件内容流
|
||||
func (s *UploadService) Upload(module string, filename string, contentType string, fileSize int64, reader io.Reader) (string, error) {
|
||||
// 校验文件大小
|
||||
if fileSize > s.cfg.Upload.MaxSize {
|
||||
return "", fmt.Errorf("文件大小超过限制(最大 %d 字节)", s.cfg.Upload.MaxSize)
|
||||
@@ -41,9 +56,15 @@ func (s *UploadService) Upload(filename string, contentType string, fileSize int
|
||||
return "", fmt.Errorf("不支持的文件类型: %s", contentType)
|
||||
}
|
||||
|
||||
// 生成存储 key:按日期分目录 + 时间戳文件名
|
||||
// 校验并规范化模块名
|
||||
mod := allowedModules[module]
|
||||
if mod == "" {
|
||||
mod = "common"
|
||||
}
|
||||
|
||||
// 生成存储 key:模块名/日期/时间戳文件名
|
||||
ext := filepath.Ext(filename)
|
||||
key := fmt.Sprintf("%s%s", time.Now().Format("2006/01/02/150405.000000"), ext)
|
||||
key := fmt.Sprintf("%s/%s%s", mod, time.Now().Format("2006/01/02/150405.000000"), ext)
|
||||
|
||||
url, err := s.storage.Upload(key, reader, contentType)
|
||||
if err != nil {
|
||||
@@ -52,3 +73,109 @@ func (s *UploadService) Upload(filename string, contentType string, fileSize int
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// FileInfo 文件信息
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"mod_time"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
// ListFiles 列出指定模块下的所有文件(递归遍历子目录)
|
||||
func (s *UploadService) ListFiles(module string) ([]FileInfo, error) {
|
||||
mod := allowedModules[module]
|
||||
if mod == "" {
|
||||
mod = "common"
|
||||
}
|
||||
|
||||
dirPath := filepath.Join(s.cfg.Upload.Path, mod)
|
||||
var result []FileInfo
|
||||
|
||||
// 递归遍历
|
||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // 跳过错误
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil // 跳过目录
|
||||
}
|
||||
// 计算相对路径作为 key
|
||||
relPath, err := filepath.Rel(s.cfg.Upload.Path, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
relPath = filepath.ToSlash(relPath) // 统一用 /
|
||||
url := s.storage.GetURL(relPath)
|
||||
result = append(result, FileInfo{
|
||||
Name: info.Name(),
|
||||
URL: url,
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
IsDir: false,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []FileInfo{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 按修改时间倒序
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ModTime > result[j].ModTime
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除指定模块下的文件
|
||||
// filename 可以是文件名或完整相对路径(如 2026/07/10/xxx.png)
|
||||
func (s *UploadService) DeleteFile(module, filename string) error {
|
||||
mod := allowedModules[module]
|
||||
if mod == "" {
|
||||
mod = "common"
|
||||
}
|
||||
|
||||
// 防止路径穿越:去掉模块名前缀(如果有的话)和 ..
|
||||
cleanName := strings.ReplaceAll(filename, "..", "")
|
||||
cleanName = strings.TrimPrefix(cleanName, mod+"/")
|
||||
cleanName = strings.TrimPrefix(cleanName, mod)
|
||||
|
||||
key := filepath.Join(mod, cleanName)
|
||||
key = filepath.ToSlash(key)
|
||||
|
||||
return s.storage.Delete(key)
|
||||
}
|
||||
|
||||
// GetModules 返回所有支持的模块
|
||||
func (s *UploadService) GetModules() []map[string]string {
|
||||
return []map[string]string{
|
||||
{"key": "enterprise", "label": "企业官网"},
|
||||
{"key": "shop", "label": "跨境电商"},
|
||||
{"key": "food", "label": "外卖平台"},
|
||||
{"key": "game", "label": "游戏平台"},
|
||||
{"key": "app", "label": "应用商店"},
|
||||
{"key": "music", "label": "音乐平台"},
|
||||
{"key": "common", "label": "公共资源"},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureUploadDir 确保上传目录和各模块子目录存在
|
||||
func (s *UploadService) EnsureUploadDir() {
|
||||
basePath := s.cfg.Upload.Path
|
||||
for mod := range allowedModules {
|
||||
os.MkdirAll(filepath.Join(basePath, mod), 0755)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename 清理文件名中的危险字符
|
||||
func sanitizeFilename(name string) string {
|
||||
name = strings.ReplaceAll(name, "..", "")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
return name
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user