mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
feat(upload): refactor file management with statistics and multi-tab layout
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
@@ -47,6 +48,7 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
|
||||
|
||||
uploadGroup.POST("", UploadFile)
|
||||
uploadGroup.GET("/my", ListMyFiles)
|
||||
uploadGroup.GET("/stats", GetFileStats)
|
||||
uploadGroup.GET("/download/:id", DownloadFile)
|
||||
uploadGroup.POST("/download/batch", BatchDownloadFiles)
|
||||
return r
|
||||
@@ -710,3 +712,113 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileStats(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
authUser := &model.User{ID: 1001, Username: "test_user"}
|
||||
router := setupTestRouter(authUser)
|
||||
|
||||
// Insert some dummy uploads
|
||||
uploads := []model.Upload{
|
||||
{
|
||||
ID: 3101,
|
||||
UserID: authUser.ID,
|
||||
FileName: "photo.png",
|
||||
FilePath: "uploads/photo.png",
|
||||
FileSize: 100,
|
||||
MimeType: "image/png",
|
||||
Extension: "png",
|
||||
StorageDriver: "local",
|
||||
Type: "generic",
|
||||
Status: model.UploadStatusUsed,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 3102,
|
||||
UserID: authUser.ID,
|
||||
FileName: "video.mp4",
|
||||
FilePath: "uploads/video.mp4",
|
||||
FileSize: 500,
|
||||
MimeType: "video/mp4",
|
||||
Extension: "mp4",
|
||||
StorageDriver: "local",
|
||||
Type: "generic",
|
||||
Status: model.UploadStatusUsed,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -2), // 2 days ago
|
||||
},
|
||||
{
|
||||
ID: 3103,
|
||||
UserID: authUser.ID,
|
||||
FileName: "document.pdf",
|
||||
FilePath: "uploads/document.pdf",
|
||||
FileSize: 200,
|
||||
MimeType: "application/pdf",
|
||||
Extension: "pdf",
|
||||
StorageDriver: "local",
|
||||
Type: "avatar", // different type
|
||||
Status: model.UploadStatusUsed,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -10), // older than 7 days
|
||||
},
|
||||
}
|
||||
|
||||
for i := range uploads {
|
||||
if err := dbConn.Create(&uploads[i]).Error; err != nil {
|
||||
t.Fatalf("failed to create upload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/upload/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
Data fileStatsResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if resp.ErrorMsg != "" {
|
||||
t.Fatalf("expected no error, got: %s", resp.ErrorMsg)
|
||||
}
|
||||
|
||||
// Verify total count and size
|
||||
if resp.Data.TotalCount != 3 {
|
||||
t.Errorf("expected 3 total files, got %d", resp.Data.TotalCount)
|
||||
}
|
||||
if resp.Data.TotalSize != 800 {
|
||||
t.Errorf("expected 800 total size, got %d", resp.Data.TotalSize)
|
||||
}
|
||||
|
||||
// Verify trend (last 7 days should include photo.png (100) and video.mp4 (500), but NOT pdf (older))
|
||||
// Total size in trend should be 600
|
||||
var trendSizeSum int64
|
||||
for _, trendItem := range resp.Data.Trend {
|
||||
trendSizeSum += trendItem.Size
|
||||
}
|
||||
if trendSizeSum != 600 {
|
||||
t.Errorf("expected 7-day trend size sum to be 600, got %d", trendSizeSum)
|
||||
}
|
||||
|
||||
// Verify categories
|
||||
categoryMap := make(map[string]int64)
|
||||
for _, cat := range resp.Data.Categories {
|
||||
categoryMap[cat.Name] = cat.Count
|
||||
}
|
||||
if categoryMap["图片"] != 1 {
|
||||
t.Errorf("expected 1 image category, got %d", categoryMap["图片"])
|
||||
}
|
||||
if categoryMap["视频"] != 1 {
|
||||
t.Errorf("expected 1 video category, got %d", categoryMap["视频"])
|
||||
}
|
||||
if categoryMap["文档"] != 1 {
|
||||
t.Errorf("expected 1 document category, got %d", categoryMap["文档"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package upload
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type trendItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type distributionItem struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type fileStatsResponse struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
Trend []trendItem `json:"trend"`
|
||||
Categories []distributionItem `json:"categories"`
|
||||
Types []distributionItem `json:"types"`
|
||||
}
|
||||
|
||||
// GetFileStats 获取当前登录用户的文件统计数据
|
||||
// @Summary 获取文件统计数据
|
||||
// @Description 返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据
|
||||
// @Tags upload
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=fileStatsResponse} "获取成功"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/upload/stats [get]
|
||||
func GetFileStats(c *gin.Context) {
|
||||
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||
ctx := c.Request.Context()
|
||||
userID := currUser.ID
|
||||
|
||||
// 1. 获取总文件数与总文件大小
|
||||
var summary struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
}
|
||||
err := db.DB(ctx).Model(&model.Upload{}).
|
||||
Select("COUNT(*) as total_count, COALESCE(SUM(file_size), 0) as total_size").
|
||||
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
|
||||
Scan(&summary).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取业务类型分布 (Group By type)
|
||||
type rawDist struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Count int64 `gorm:"column:count"`
|
||||
Size int64 `gorm:"column:size"`
|
||||
}
|
||||
var typeRaw []rawDist
|
||||
err = db.DB(ctx).Model(&model.Upload{}).
|
||||
Select("type as key, COUNT(*) as count, COALESCE(SUM(file_size), 0) as size").
|
||||
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
|
||||
Group("type").
|
||||
Scan(&typeRaw).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
types := make([]distributionItem, 0, len(typeRaw))
|
||||
for _, tr := range typeRaw {
|
||||
name := tr.Key
|
||||
if name == "" {
|
||||
name = "generic"
|
||||
}
|
||||
types = append(types, distributionItem{
|
||||
Name: name,
|
||||
Count: tr.Count,
|
||||
Size: tr.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 获取所有文件的大小、后缀与MIME,用于在 Go 中内存分类统计 (避免数据库中写复杂的 JSON/String 匹配逻辑)
|
||||
type fileCategoryRaw struct {
|
||||
Extension string `gorm:"column:extension"`
|
||||
MimeType string `gorm:"column:mime_type"`
|
||||
FileSize int64 `gorm:"column:file_size"`
|
||||
}
|
||||
var fileRaws []fileCategoryRaw
|
||||
err = db.DB(ctx).Model(&model.Upload{}).
|
||||
Select("extension, mime_type, file_size").
|
||||
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
|
||||
Scan(&fileRaws).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
catCount := make(map[string]int64)
|
||||
catSize := make(map[string]int64)
|
||||
categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"}
|
||||
for _, cat := range categoriesList {
|
||||
catCount[cat] = 0
|
||||
catSize[cat] = 0
|
||||
}
|
||||
|
||||
for _, fr := range fileRaws {
|
||||
cat := getFileCategory(fr.MimeType, fr.Extension)
|
||||
catCount[cat]++
|
||||
catSize[cat] += fr.FileSize
|
||||
}
|
||||
|
||||
categories := make([]distributionItem, 0, len(categoriesList))
|
||||
for _, cat := range categoriesList {
|
||||
categories = append(categories, distributionItem{
|
||||
Name: cat,
|
||||
Count: catCount[cat],
|
||||
Size: catSize[cat],
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 获取近 7 天的新增文件趋势 (在 Go 中补全没有新增记录的日期为 0)
|
||||
type fileTrendRaw struct {
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
FileSize int64 `gorm:"column:file_size"`
|
||||
}
|
||||
var trendRaws []fileTrendRaw
|
||||
// 7天前 00:00:00 (即 6 天前 00:00:00 至今天)
|
||||
now := time.Now()
|
||||
startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6)
|
||||
err = db.DB(ctx).Model(&model.Upload{}).
|
||||
Select("created_at, file_size").
|
||||
Where("user_id = ? AND status != ? AND created_at >= ?", userID, model.UploadStatusDeleted, startTime).
|
||||
Scan(&trendRaws).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
trendCountMap := make(map[string]int64)
|
||||
trendSizeMap := make(map[string]int64)
|
||||
for i := 0; i < 7; i++ {
|
||||
dStr := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
trendCountMap[dStr] = 0
|
||||
trendSizeMap[dStr] = 0
|
||||
}
|
||||
|
||||
for _, tr := range trendRaws {
|
||||
dStr := tr.CreatedAt.Format("2006-01-02")
|
||||
if _, exists := trendCountMap[dStr]; exists {
|
||||
trendCountMap[dStr]++
|
||||
trendSizeMap[dStr] += tr.FileSize
|
||||
}
|
||||
}
|
||||
|
||||
const trendDays = 7
|
||||
trend := make([]trendItem, 0, trendDays)
|
||||
for i := trendDays - 1; i >= 0; i-- {
|
||||
dStr := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
trend = append(trend, trendItem{
|
||||
Date: dStr,
|
||||
Count: trendCountMap[dStr],
|
||||
Size: trendSizeMap[dStr],
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(fileStatsResponse{
|
||||
TotalCount: summary.TotalCount,
|
||||
TotalSize: summary.TotalSize,
|
||||
Trend: trend,
|
||||
Categories: categories,
|
||||
Types: types,
|
||||
}))
|
||||
}
|
||||
|
||||
func getFileCategory(mimeType, ext string) string {
|
||||
mimeType = strings.ToLower(mimeType)
|
||||
ext = strings.ToLower(ext)
|
||||
|
||||
if strings.HasPrefix(mimeType, "image/") || isImageExtension(ext) {
|
||||
return "图片"
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "video/") {
|
||||
return "视频"
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "audio/") {
|
||||
return "音频"
|
||||
}
|
||||
if isArchiveExtension(ext) || strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || strings.Contains(mimeType, "gzip") {
|
||||
return "压缩包"
|
||||
}
|
||||
if isDocumentExtension(ext) || strings.HasPrefix(mimeType, "text/") || mimeType == "application/pdf" {
|
||||
return "文档"
|
||||
}
|
||||
return "其他"
|
||||
}
|
||||
|
||||
func isArchiveExtension(ext string) bool {
|
||||
for _, e := range []string{"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"} {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isDocumentExtension(ext string) bool {
|
||||
for _, e := range []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "json", "yaml", "yml", "xml"} {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -179,8 +179,20 @@ func registerRoutes(r *gin.Engine) {
|
||||
}
|
||||
return enabled
|
||||
}), user.Login)
|
||||
userRouter.POST("/register", user.Register)
|
||||
userRouter.POST("/send-email-code", user.SendEmailCode)
|
||||
userRouter.POST("/register", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "register", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), user.Register)
|
||||
userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "send_email_code", func() bool {
|
||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}), user.SendEmailCode)
|
||||
userRouter.GET("/logout", user.Logout)
|
||||
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
||||
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||
@@ -203,6 +215,7 @@ func registerRoutes(r *gin.Engine) {
|
||||
{
|
||||
uploadRouter.POST("", upload.UploadFile)
|
||||
uploadRouter.GET("/my", upload.ListMyFiles)
|
||||
uploadRouter.GET("/stats", upload.GetFileStats)
|
||||
uploadRouter.DELETE("/:id", upload.DeleteFile)
|
||||
uploadRouter.GET("/download/:id", upload.DownloadFile)
|
||||
uploadRouter.POST("/download/batch", upload.BatchDownloadFiles)
|
||||
|
||||
Reference in New Issue
Block a user