refactor(storage): move file management routes from user to admin namespace

- Remove file list, stats, download, and deletion routes from '/api/v1/upload'
- Move these endpoints under '/api/v1/admin/uploads'
- Remove user-specific filtering from files query and statistics to aggregate system-wide uploads by default
- Allow admins to bypass ownership check when downloading private files
- Update backend unit tests, Swagger documentation, and frontend service client and components
This commit is contained in:
ryan
2026-06-13 16:04:21 +08:00
parent dbabe8b8d7
commit 8b19ffed90
13 changed files with 908 additions and 818 deletions
+20 -21
View File
@@ -10,7 +10,6 @@ import (
"strconv"
"strings"
"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"
@@ -18,40 +17,42 @@ import (
"gorm.io/gorm"
)
type listMyFilesRequest struct {
type listFilesRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"`
Type string `form:"type"`
Extension string `form:"extension"`
UserID uint64 `form:"user_id"`
}
type listMyFilesResponse struct {
type listFilesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []model.Upload `json:"items"`
}
// ListMyFiles 获取当前用户上传的文件列表
// @Summary 获取我的文件列表
// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤
// @Tags upload
// ListFiles 获取系统上传的文件列表
// @Summary 获取文件列表
// @Description 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤
// @Tags admin
// @Produce json
// @Param page query int false "页码(默认 1)"
// @Param page_size query int false "每页数量(默认 20,最大 100)"
// @Param keyword query string false "文件名关键词(模糊匹配)"
// @Param type query string false "业务分类过滤"
// @Param extension query string false "扩展名过滤"
// @Param user_id query uint64 false "上传用户 ID"
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=listMyFilesResponse} "查询成功"
// @Success 200 {object} util.ResponseAny{data=listFilesResponse} "查询成功"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Router /api/v1/upload/my [get]
func ListMyFiles(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Router /api/v1/admin/uploads [get]
func ListFiles(c *gin.Context) {
ctx := c.Request.Context()
var req listMyFilesRequest
var req listFilesRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusOK, util.Err(ErrInvalidParams))
return
@@ -64,8 +65,11 @@ func ListMyFiles(c *gin.Context) {
}
query := db.DB(ctx).Model(&model.Upload{}).
Where("user_id = ? AND status != ?", currUser.ID, model.UploadStatusDeleted)
Where("status != ?", model.UploadStatusDeleted)
if req.UserID != 0 {
query = query.Where("user_id = ?", req.UserID)
}
if req.Keyword != "" {
query = query.Where("LOWER(file_name) LIKE ?", "%"+strings.ToLower(req.Keyword)+"%")
}
@@ -89,7 +93,7 @@ func ListMyFiles(c *gin.Context) {
return
}
c.JSON(http.StatusOK, util.OK(listMyFilesResponse{
c.JSON(http.StatusOK, util.OK(listFilesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
@@ -100,16 +104,15 @@ func ListMyFiles(c *gin.Context) {
// DeleteFile 软删除文件记录
// @Summary 删除文件
// @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象
// @Tags upload
// @Tags admin
// @Produce json
// @Param id path string true "文件 ID"
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny "删除成功"
// @Failure 403 {object} util.ResponseAny "无权操作"
// @Failure 404 {object} util.ResponseAny "文件不存在"
// @Router /api/v1/upload/{id} [delete]
// @Router /api/v1/admin/uploads/{id} [delete]
func DeleteFile(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
if storageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
@@ -131,10 +134,6 @@ func DeleteFile(c *gin.Context) {
c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed))
return
}
if upload.UserID != currUser.ID && !currUser.IsAdmin {
c.AbortWithStatus(http.StatusForbidden)
return
}
if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
c.JSON(http.StatusOK, util.Err(ErrDeleteFileFailed))
return
+3
View File
@@ -292,6 +292,9 @@ func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error {
return err
}
}
if currUser.IsAdmin {
return nil
}
if currUser.ID != ownerID {
return errors.New("forbidden: cross-user access denied")
}
+5 -5
View File
@@ -189,7 +189,7 @@ func UploadFile(c *gin.Context) {
// DownloadFile 通用单文件下载接口
// @Summary 下载单文件
// @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
// @Tags upload
// @Tags admin
// @Produce octet-stream
// @Param id path string true "文件 ID"
// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin"
@@ -198,7 +198,7 @@ func UploadFile(c *gin.Context) {
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 404 {object} util.ResponseAny "文件不存在"
// @Failure 500 {object} util.ResponseAny "服务内部错误"
// @Router /api/v1/upload/download/{id} [get]
// @Router /api/v1/admin/uploads/download/{id} [get]
func DownloadFile(c *gin.Context) {
upload, err := getUploadRecordByID(c)
if err != nil {
@@ -241,15 +241,15 @@ func DownloadFile(c *gin.Context) {
// BatchDownloadFiles 批量打包 ZIP 下载接口
// @Summary 批量打包下载
// @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突
// @Tags upload
// @Tags admin
// @Accept json
// @Produce octet-stream
// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组的请求体"
// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组 of string 的请求体"
// @Security SessionCookie
// @Success 200 {file} file "成功下载打包后的 ZIP"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Failure 500 {object} util.ResponseAny "打包失败"
// @Router /api/v1/upload/download/batch [post]
// @Router /api/v1/admin/uploads/download/batch [post]
func BatchDownloadFiles(c *gin.Context) {
ctx := c.Request.Context()
+66 -32
View File
@@ -36,21 +36,30 @@ type testResponse struct {
func setupTestRouter(authUser *model.User) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
uploadGroup := r.Group("/api/v1/upload")
// Mock authentication middleware
uploadGroup.Use(func(c *gin.Context) {
authMiddleware := func(c *gin.Context) {
if authUser != nil {
util.SetToContext(c, oauth.UserObjKey, authUser)
}
c.Next()
})
}
uploadGroup := r.Group("/api/v1/upload")
uploadGroup.Use(authMiddleware)
{
uploadGroup.POST("", UploadFile)
}
adminGroup := r.Group("/api/v1/admin/uploads")
adminGroup.Use(authMiddleware)
{
adminGroup.GET("", ListFiles)
adminGroup.GET("/stats", GetFileStats)
adminGroup.DELETE("/:id", DeleteFile)
adminGroup.GET("/download/:id", DownloadFile)
adminGroup.POST("/download/batch", BatchDownloadFiles)
}
uploadGroup.POST("", UploadFile)
uploadGroup.GET("/my", ListMyFiles)
uploadGroup.GET("/stats", GetFileStats)
uploadGroup.GET("/download/:id", DownloadFile)
uploadGroup.POST("/download/batch", BatchDownloadFiles)
return r
}
@@ -356,7 +365,7 @@ func TestDownloadFile(t *testing.T) {
dbConn.Create(&localUpload)
t.Run("download file successfully", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/upload/download/2001", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/2001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -381,7 +390,7 @@ func TestDownloadFile(t *testing.T) {
})
t.Run("download non-existent file", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/upload/download/9999", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/9999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -391,7 +400,7 @@ func TestDownloadFile(t *testing.T) {
})
}
func TestListMyFiles(t *testing.T) {
func TestListFiles(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -451,7 +460,7 @@ func TestListMyFiles(t *testing.T) {
}
t.Run("returns requested page", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/upload/my?page=2&page_size=2", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?page=2&page_size=2", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -460,29 +469,29 @@ func TestListMyFiles(t *testing.T) {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("ListMyFiles() error = %q, want empty", resp.ErrorMsg)
t.Fatalf("ListFiles() error = %q, want empty", resp.ErrorMsg)
}
var got listMyFilesResponse
var got listFilesResponse
if err := json.Unmarshal(resp.Data, &got); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if got.Page != 2 {
t.Errorf("ListMyFiles(page=2).Page = %d, want 2", got.Page)
t.Errorf("ListFiles(page=2).Page = %d, want 2", got.Page)
}
if got.PageSize != 2 {
t.Errorf("ListMyFiles(page_size=2).PageSize = %d, want 2", got.PageSize)
t.Errorf("ListFiles(page_size=2).PageSize = %d, want 2", got.PageSize)
}
if got.Total != 3 {
t.Errorf("ListMyFiles().Total = %d, want 3", got.Total)
if got.Total != 4 {
t.Errorf("ListFiles().Total = %d, want 4", got.Total)
}
if len(got.Items) != 1 {
t.Fatalf("ListMyFiles(page=2, page_size=2) returned %d items, want 1", len(got.Items))
if len(got.Items) != 2 {
t.Fatalf("ListFiles(page=2, page_size=2) returned %d items, want 2", len(got.Items))
}
})
t.Run("filters filename case insensitively", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/upload/my?keyword=photo", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?keyword=photo", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -491,21 +500,46 @@ func TestListMyFiles(t *testing.T) {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("ListMyFiles(keyword=photo) error = %q, want empty", resp.ErrorMsg)
t.Fatalf("ListFiles(keyword=photo) error = %q, want empty", resp.ErrorMsg)
}
var got listMyFilesResponse
var got listFilesResponse
if err := json.Unmarshal(resp.Data, &got); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if got.Total != 1 {
t.Errorf("ListMyFiles(keyword=photo).Total = %d, want 1", got.Total)
t.Errorf("ListFiles(keyword=photo).Total = %d, want 1", got.Total)
}
if len(got.Items) != 1 {
t.Fatalf("ListMyFiles(keyword=photo) returned %d items, want 1", len(got.Items))
t.Fatalf("ListFiles(keyword=photo) returned %d items, want 1", len(got.Items))
}
if got.Items[0].FileName != "Second-Photo.PNG" {
t.Errorf("ListMyFiles(keyword=photo).Items[0].FileName = %q, want %q", got.Items[0].FileName, "Second-Photo.PNG")
t.Errorf("ListFiles(keyword=photo).Items[0].FileName = %q, want %q", got.Items[0].FileName, "Second-Photo.PNG")
}
})
t.Run("filters by user_id", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?user_id=1001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp testResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("ListFiles(user_id=1001) error = %q, want empty", resp.ErrorMsg)
}
var got listFilesResponse
if err := json.Unmarshal(resp.Data, &got); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if got.Total != 3 {
t.Errorf("ListFiles(user_id=1001).Total = %d, want 3", got.Total)
}
if len(got.Items) != 3 {
t.Fatalf("ListFiles(user_id=1001) returned %d items, want 3", len(got.Items))
}
})
}
@@ -573,7 +607,7 @@ func TestBatchDownloadFiles(t *testing.T) {
reqBody, _ := json.Marshal(batchDownloadRequest{
IDs: []string{"3001", "3002", "3003"},
})
req, _ := http.NewRequest("POST", "/api/v1/upload/download/batch", bytes.NewReader(reqBody))
req, _ := http.NewRequest("POST", "/api/v1/admin/uploads/download/batch", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -688,7 +722,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
// 3. Verify accessing private file as user1 (owner) succeeds
wAccessOwner := httptest.NewRecorder()
reqAccessOwner, _ := http.NewRequest("GET", "/api/v1/upload/download/"+strconv.FormatUint(upload1.ID, 10), nil)
reqAccessOwner, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload1.ID, 10), nil)
router.ServeHTTP(wAccessOwner, reqAccessOwner)
if wAccessOwner.Code != http.StatusOK {
t.Errorf("owner should be allowed to download private file, got status %d", wAccessOwner.Code)
@@ -697,7 +731,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
// 4. Verify accessing private file as user2 (non-owner) fails
routerUser2 := setupTestRouter(user2)
wAccessOther := httptest.NewRecorder()
reqAccessOther, _ := http.NewRequest("GET", "/api/v1/upload/download/"+strconv.FormatUint(upload1.ID, 10), nil)
reqAccessOther, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload1.ID, 10), nil)
routerUser2.ServeHTTP(wAccessOther, reqAccessOther)
if wAccessOther.Code != http.StatusUnauthorized {
t.Errorf("non-owner should be denied download of private file, got status %d, want 401", wAccessOther.Code)
@@ -705,7 +739,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
// 5. Verify accessing public file as user2 (non-owner) succeeds
wAccessPublic := httptest.NewRecorder()
reqAccessPublic, _ := http.NewRequest("GET", "/api/v1/upload/download/"+strconv.FormatUint(upload2.ID, 10), nil)
reqAccessPublic, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload2.ID, 10), nil)
routerUser2.ServeHTTP(wAccessPublic, reqAccessPublic)
if wAccessPublic.Code != http.StatusOK {
t.Errorf("any logged-in user should be allowed to download public file, got status %d", wAccessPublic.Code)
@@ -768,7 +802,7 @@ func TestGetFileStats(t *testing.T) {
}
}
req, _ := http.NewRequest("GET", "/api/v1/upload/stats", nil)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
+9 -11
View File
@@ -8,7 +8,6 @@ import (
"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"
@@ -35,20 +34,19 @@ type fileStatsResponse struct {
Types []distributionItem `json:"types"`
}
// GetFileStats 获取当前登录用户的文件统计数据
// GetFileStats 获取系统上传的文件统计数据
// @Summary 获取文件统计数据
// @Description 返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据
// @Tags upload
// @Description 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} util.ResponseAny{data=fileStatsResponse} "获取成功"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Failure 403 {object} util.ResponseAny "无管理员权限"
// @Failure 500 {object} util.ResponseAny "内部错误"
// @Router /api/v1/upload/stats [get]
// @Router /api/v1/admin/uploads/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 {
@@ -57,7 +55,7 @@ func GetFileStats(c *gin.Context) {
}
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).
Where("status != ?", model.UploadStatusDeleted).
Scan(&summary).Error
if err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
@@ -73,7 +71,7 @@ func GetFileStats(c *gin.Context) {
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).
Where("status != ?", model.UploadStatusDeleted).
Group("type").
Scan(&typeRaw).Error
if err != nil {
@@ -103,7 +101,7 @@ func GetFileStats(c *gin.Context) {
var fileRaws []fileCategoryRaw
err = db.DB(ctx).Model(&model.Upload{}).
Select("extension, mime_type, file_size").
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
Where("status != ?", model.UploadStatusDeleted).
Scan(&fileRaws).Error
if err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
@@ -144,7 +142,7 @@ func GetFileStats(c *gin.Context) {
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).
Where("status != ? AND created_at >= ?", model.UploadStatusDeleted, startTime).
Scan(&trendRaws).Error
if err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
+9 -6
View File
@@ -214,11 +214,6 @@ func registerRoutes(r *gin.Engine) {
uploadRouter.Use(oauth.LoginRequired())
{
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)
}
// Config (public)
@@ -282,7 +277,15 @@ func registerRoutes(r *gin.Engine) {
adminRouter.DELETE("/users/:id", admin_user.DeleteUser)
// Uploads
adminRouter.GET("/uploads/types", upload.GetDistinctUploadTypes)
adminUploadsRouter := adminRouter.Group("/uploads")
{
adminUploadsRouter.GET("", upload.ListFiles)
adminUploadsRouter.GET("/stats", upload.GetFileStats)
adminUploadsRouter.DELETE("/:id", upload.DeleteFile)
adminUploadsRouter.GET("/download/:id", upload.DownloadFile)
adminUploadsRouter.POST("/download/batch", upload.BatchDownloadFiles)
adminUploadsRouter.GET("/types", upload.GetDistinctUploadTypes)
}
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)