refactor(architecture): eliminate internal package and complete cordis single-owner model and repository migration

- Physically purged all legacy internal/ packages, centralized pkg/model/ and pkg/repository/
- Migrated domain models and database repositories into self-contained owner plugins (user, auth, message_gateway, admin, upload, risk_control)
- Decoupled cross-plugin interactions via pure core/contracts and typed EventBus
- Ensured 100% test coverage pass, zero data races (-race clean), and 0 lint issues in make code-check
This commit is contained in:
ryan
2026-08-28 08:40:43 +08:00
parent 1f348fd425
commit fb6a3edb89
323 changed files with 8222 additions and 17693 deletions
+7 -7
View File
@@ -11,13 +11,11 @@ import (
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
db "github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/util"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
)
const fileAccessInvalidationChannel = "upload:file_access_invalidation"
@@ -53,12 +51,13 @@ func ensureAccessCacheListener() {
}
func startAccessCacheInvalidationListener() {
if db.Redis == nil {
rdb := db.Redis
if rdb == nil {
return
}
util.Go(func() {
pubsub := db.Redis.Subscribe(
pubsub := rdb.Subscribe(
context.Background(),
objectstore.ConfigInvalidationChannel,
fileAccessInvalidationChannel,
@@ -114,7 +113,8 @@ func fetchFileAccessWhitelist(ctx context.Context) map[string]struct{} {
}
func parseFileAccessWhitelist(ctx context.Context) []string {
sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyFileAccessWhitelist)
var sc struct{ Value string }
err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error
if err != nil || sc.Value == "" {
return []string{shared.DefaultPublicUploadType}
}
+3 -15
View File
@@ -8,10 +8,7 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
)
@@ -60,18 +57,9 @@ func TestResetAccessCachesRefreshesWhitelist(t *testing.T) {
t.Fatal("expected seeded avatar whitelist before reset")
}
var sc model.SystemConfig
if err := dbConn.Where("key = ?", model.ConfigKeyFileAccessWhitelist).First(&sc).Error; err != nil {
t.Fatalf("load whitelist config: %v", err)
if err := dbConn.Table("w_system_configs").Where("key = ?", "file_access_whitelist").Update("value", `["attachment"]`).Error; err != nil {
t.Fatalf("update whitelist config: %v", err)
}
sc.Value = `["attachment"]`
if err := dbConn.Save(&sc).Error; err != nil {
t.Fatalf("save whitelist config: %v", err)
}
if err := db.HSetJSON(ctx, repository.SystemConfigRedisHashKey, model.ConfigKeyFileAccessWhitelist, &sc); err != nil {
t.Fatalf("refresh whitelist redis cache: %v", err)
}
repository.ResetSystemConfigRAMCacheForTest()
ResetAccessCaches()
if !IsFilePublic(ctx, "attachment") {
+23 -23
View File
@@ -9,10 +9,10 @@ import (
"fmt"
"sync"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
db "github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/util"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
)
const (
@@ -26,7 +26,7 @@ type uploadMetaInvalidationMessage struct {
}
var (
uploadMetaRAM = ram.MustNew[uint64, model.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize})
uploadMetaRAM = ram.MustNew[uint64, models.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize})
uploadMetaListenerOnce sync.Once
uploadMetaListenerCtx context.Context
uploadMetaListenerCancel context.CancelFunc
@@ -37,8 +37,8 @@ func uploadMetaRedisKey(id uint64) string {
return fmt.Sprintf("upload:meta:%d", id)
}
func cloneUpload(upload model.Upload) model.Upload {
return upload
func cloneUpload(u models.Upload) models.Upload {
return u
}
func ensureUploadMetaCacheListener() {
@@ -88,45 +88,45 @@ func publishUploadMetaRAMInvalidation(ctx context.Context, id uint64) {
}
// GetUploadByID loads upload metadata from RAM, Redis, or the database.
func GetUploadByID(ctx context.Context, id uint64) (model.Upload, error) {
func GetUploadByID(ctx context.Context, id uint64) (models.Upload, error) {
ensureUploadMetaCacheListener()
if upload, ok := uploadMetaRAM.GetIfPresent(id); ok {
return cloneUpload(upload), nil
if u, ok := uploadMetaRAM.GetIfPresent(id); ok {
return cloneUpload(u), nil
}
key := uploadMetaRedisKey(id)
if db.Redis != nil {
var upload model.Upload
if err := db.GetJSON(ctx, key, &upload); err == nil {
uploadMetaRAM.Set(id, cloneUpload(upload))
return upload, nil
var u models.Upload
if err := db.GetJSON(ctx, key, &u); err == nil {
uploadMetaRAM.Set(id, cloneUpload(u))
return u, nil
}
}
var upload model.Upload
var u models.Upload
if err := db.DB(ctx).
Where("id = ? AND status IN (?, ?)", id, model.UploadStatusPending, model.UploadStatusUsed).
First(&upload).Error; err != nil {
return model.Upload{}, err
Where("id = ? AND status IN (?, ?)", id, models.UploadStatusPending, models.UploadStatusUsed).
First(&u).Error; err != nil {
return models.Upload{}, err
}
SetUploadMetaCache(ctx, &upload)
return upload, nil
SetUploadMetaCache(ctx, &u)
return u, nil
}
// SetUploadMetaCache populates RAM and Redis upload metadata caches.
func SetUploadMetaCache(ctx context.Context, upload *model.Upload) {
func SetUploadMetaCache(ctx context.Context, u *models.Upload) {
ensureUploadMetaCacheListener()
if upload == nil {
if u == nil {
return
}
cloned := cloneUpload(*upload)
uploadMetaRAM.Set(upload.ID, cloned)
cloned := cloneUpload(*u)
uploadMetaRAM.Set(u.ID, cloned)
if db.Redis != nil {
_ = db.SetJSON(ctx, uploadMetaRedisKey(upload.ID), cloned, uploadMetaRedisCacheTTL)
_ = db.SetJSON(ctx, uploadMetaRedisKey(u.ID), cloned, uploadMetaRedisCacheTTL)
}
}
+22 -22
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"gorm.io/gorm"
)
@@ -22,7 +22,7 @@ func init() {
})
}
func seedUpload(t *testing.T, dbConn *gorm.DB, upload model.Upload) {
func seedUpload(t *testing.T, dbConn *gorm.DB, upload models.Upload) {
t.Helper()
if err := dbConn.Create(&upload).Error; err != nil {
t.Fatalf("create upload: %v", err)
@@ -35,7 +35,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := model.Upload{
upload := models.Upload{
ID: 91001,
UserID: 1,
FileName: "cached.png",
@@ -44,7 +44,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
@@ -57,7 +57,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
t.Fatalf("unexpected upload: %+v", got)
}
var redisUpload model.Upload
var redisUpload models.Upload
if err := db.GetJSON(ctx, uploadMetaRedisKey(upload.ID), &redisUpload); err != nil {
t.Fatalf("redis cache miss after DB load: %v", err)
}
@@ -65,7 +65,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
t.Fatalf("redis upload id mismatch: got=%d want=%d", redisUpload.ID, upload.ID)
}
if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil {
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
@@ -84,7 +84,7 @@ func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) {
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := model.Upload{
upload := models.Upload{
ID: 91002,
UserID: 1,
FileName: "redis.png",
@@ -93,14 +93,14 @@ func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusPending,
Status: models.UploadStatusPending,
AccessMode: 0,
}
seedUpload(t, dbConn, upload)
SetUploadMetaCache(ctx, &upload)
ResetUploadMetaCacheForTest()
if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil {
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
@@ -119,7 +119,7 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := model.Upload{
upload := models.Upload{
ID: 91003,
UserID: 1,
FileName: "invalidate.png",
@@ -128,7 +128,7 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
@@ -136,7 +136,7 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
InvalidateUploadMetaCache(ctx, upload.ID)
var redisUpload model.Upload
var redisUpload models.Upload
if err := db.GetJSON(ctx, uploadMetaRedisKey(upload.ID), &redisUpload); err == nil {
t.Fatal("expected redis cache to be invalidated")
}
@@ -159,7 +159,7 @@ func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) {
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := model.Upload{
upload := models.Upload{
ID: 91006,
UserID: 1,
FileName: "pubsub.png",
@@ -168,7 +168,7 @@ func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
@@ -177,7 +177,7 @@ func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) {
t.Fatalf("GetUploadByID: %v", err)
}
time.Sleep(50 * time.Millisecond) // allow pub/sub listener to subscribe
if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil {
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
if _, err := GetUploadByID(ctx, upload.ID); err != nil {
@@ -219,7 +219,7 @@ func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) {
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := model.Upload{
upload := models.Upload{
ID: 91004,
UserID: 1,
FileName: "deleted.png",
@@ -228,7 +228,7 @@ func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusDeleted,
Status: models.UploadStatusDeleted,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
@@ -251,7 +251,7 @@ func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) {
})
ctx := context.Background()
upload := model.Upload{
upload := models.Upload{
ID: 91005,
UserID: 1,
FileName: "ram-only.png",
@@ -260,7 +260,7 @@ func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
@@ -273,7 +273,7 @@ func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) {
t.Fatalf("unexpected upload: %+v", got)
}
if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil {
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
+1 -1
View File
@@ -4,7 +4,7 @@
package upload
import (
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/handler"
+34 -25
View File
@@ -15,15 +15,16 @@ import (
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/infra/diskcache"
"github.com/Rain-kl/Wavelet/internal/model"
appshared "github.com/Rain-kl/Wavelet/internal/shared"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/response"
appshared "github.com/Rain-kl/Wavelet/pkg/shared"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/util"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-gonic/gin"
@@ -85,7 +86,7 @@ func ServeFileByID(c *gin.Context) {
}
// GetUploadRecordByID 从请求路径参数中解析文件 ID 并从数据库中检索处于 Pending 或 Used 状态的上传记录。
func GetUploadRecordByID(c *gin.Context) (*model.Upload, error) {
func GetUploadRecordByID(c *gin.Context) (*models.Upload, error) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox")
@@ -103,7 +104,7 @@ func GetUploadRecordByID(c *gin.Context) (*model.Upload, error) {
return &upload, nil
}
func getFileTypeCategory(upload *model.Upload) fileTypeCategory {
func getFileTypeCategory(upload *models.Upload) fileTypeCategory {
mime := strings.ToLower(upload.MimeType)
ext := strings.ToLower(upload.Extension)
@@ -120,7 +121,7 @@ func getFileTypeCategory(upload *model.Upload) fileTypeCategory {
}
// ServeUpload 将已存在的文件内容读取并流式响应给客户端。
func ServeUpload(c *gin.Context, upload *model.Upload) {
func ServeUpload(c *gin.Context, upload *models.Upload) {
setCacheHeaders(c, upload)
category := getFileTypeCategory(upload)
@@ -138,7 +139,7 @@ func ServeUpload(c *gin.Context, upload *model.Upload) {
}
}
func setCacheHeaders(c *gin.Context, upload *model.Upload) {
func setCacheHeaders(c *gin.Context, upload *models.Upload) {
if cache.IsFilePublic(c.Request.Context(), upload.Type) {
c.Header("Cache-Control", "public, max-age=31536000")
} else {
@@ -146,7 +147,7 @@ func setCacheHeaders(c *gin.Context, upload *model.Upload) {
}
}
func serveOriginalWithConditionalCheck(c *gin.Context, upload *model.Upload) {
func serveOriginalWithConditionalCheck(c *gin.Context, upload *models.Upload) {
etag := fmt.Sprintf(`W/"%s"`, upload.Hash)
c.Header("ETag", etag)
@@ -158,7 +159,7 @@ func serveOriginalWithConditionalCheck(c *gin.Context, upload *model.Upload) {
serveOriginal(c, upload)
}
func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string) {
func serveCompressedImage(c *gin.Context, upload *models.Upload, quality string) {
etag := fmt.Sprintf(`W/"%s-%s"`, upload.Hash, quality)
c.Header("ETag", etag)
@@ -167,7 +168,12 @@ func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string)
return
}
webpBytes, _, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality)
webpBytes, hit, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality)
if hit {
c.Header("X-Cache", "HIT")
} else {
c.Header("X-Cache", "MISS")
}
if err != nil {
if len(webpBytes) > 0 {
logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err)
@@ -185,7 +191,7 @@ func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string)
// EnsureCompressedImageCache returns cached or freshly generated WebP bytes for an upload.
func EnsureCompressedImageCache(
ctx context.Context,
upload *model.Upload,
upload *models.Upload,
quality string,
) ([]byte, bool, error) {
cacheStore := diskcache.GetGlobalCache()
@@ -211,7 +217,7 @@ func EnsureCompressedImageCache(
func generateCompressedImageCache(
ctx context.Context,
upload *model.Upload,
upload *models.Upload,
quality string,
cacheKey string,
) (compressedImageCacheResult, error) {
@@ -246,7 +252,7 @@ func generateCompressedImageCache(
}
// ImageCompressionCacheKey returns the disk cache key for a compressed upload image.
func ImageCompressionCacheKey(upload *model.Upload, quality string) string {
func ImageCompressionCacheKey(upload *models.Upload, quality string) string {
return fmt.Sprintf(
"upload_webp_v1_%d_%d_%d_%s_%s",
upload.ID,
@@ -257,7 +263,7 @@ func ImageCompressionCacheKey(upload *model.Upload, quality string) string {
)
}
func serveOriginal(c *gin.Context, upload *model.Upload) {
func serveOriginal(c *gin.Context, upload *models.Upload) {
obj, err := uploadstorage.OpenStoredObject(c.Request.Context(), upload)
if err != nil {
response.AbortNotFound(c, "文件未找到")
@@ -267,7 +273,7 @@ func serveOriginal(c *gin.Context, upload *model.Upload) {
c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil)
}
func getOriginalFileBytes(ctx context.Context, upload *model.Upload) ([]byte, error) {
func getOriginalFileBytes(ctx context.Context, upload *models.Upload) ([]byte, error) {
obj, err := uploadstorage.OpenStoredObject(ctx, upload)
if err != nil {
return nil, err
@@ -277,33 +283,36 @@ func getOriginalFileBytes(ctx context.Context, upload *model.Upload) ([]byte, er
}
func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error {
var currUser *model.User
var err error
if u, ok := auth.GetFromContext[*model.User](c, auth.UserObjKey); ok && u != nil {
currUser = u
var currUserID uint64
var isAdmin bool
if u, ok := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey); ok && u != nil {
currUserID = u.ID
isAdmin = u.IsAdmin
} else {
currUser, err = auth.GetUserFromRequest(c)
u, err := auth.GetUserFromRequest(c)
if err != nil {
return err
}
currUserID = u.ID
isAdmin = u.IsAdmin
}
if currUser.IsAdmin {
if isAdmin {
return nil
}
if currUser.ID != ownerID {
if currUserID != ownerID {
return errors.New("forbidden: cross-user access denied")
}
return nil
}
// CheckFileAccessPermission 校验文件是否可以被当前请求访问
func CheckFileAccessPermission(c *gin.Context, upload *model.Upload) error {
func CheckFileAccessPermission(c *gin.Context, upload *models.Upload) error {
if upload.AccessMode == 0 {
return checkPrivateFileOwner(c, upload.UserID)
}
if !cache.IsFilePublic(c.Request.Context(), upload.Type) {
if _, ok := auth.GetFromContext[*model.User](c, auth.UserObjKey); !ok {
if _, ok := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey); !ok {
if _, err := auth.GetUserFromRequest(c); err != nil {
return err
}
+128 -128
View File
@@ -6,8 +6,9 @@ package filesrv
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
@@ -17,21 +18,20 @@ import (
"path/filepath"
"testing"
"github.com/Rain-kl/Wavelet/internal/infra/diskcache"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
appshared "github.com/Rain-kl/Wavelet/internal/shared"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/util"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/response"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadutil "github.com/Rain-kl/Wavelet/plugins/domain/upload/util"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
)
func init() {
@@ -47,29 +47,30 @@ func TestServeFileByIDAccessControl(t *testing.T) {
configureLocalStorageRoot(t, dbConn, tempDir)
// Create a user in DB
user := model.User{
user := contracts.UserDTO{
ID: 12345,
Username: "file_test_user",
IsActive: true,
}
if err := dbConn.Create(&user).Error; err != nil {
if err := dbConn.Table("w_users").Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create an access token for this user
tokenStr := "test-secret-token-123"
tokenHash := model.HashToken(tokenStr)
tokenRecord := model.AccessToken{
UserID: user.ID,
Name: "test_token",
TokenHash: tokenHash,
tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenStr)))
tokenRecord := map[string]any{
"user_id": user.ID,
"name": "test_token",
"token_hash": tokenHash,
"masked_token": "test-***",
}
if err := dbConn.Create(&tokenRecord).Error; err != nil {
if err := dbConn.Table("w_access_tokens").Create(&tokenRecord).Error; err != nil {
t.Fatalf("failed to create token: %v", err)
}
// Create two files: one in whitelist (avatar), one not in whitelist (attachment)
avatarFile := model.Upload{
avatarFile := models.Upload{
ID: 8001,
UserID: user.ID,
FileName: "avatar.png",
@@ -78,10 +79,10 @@ func TestServeFileByIDAccessControl(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
attachmentFile := model.Upload{
attachmentFile := models.Upload{
ID: 8002,
UserID: user.ID,
FileName: "doc.pdf",
@@ -90,7 +91,7 @@ func TestServeFileByIDAccessControl(t *testing.T) {
MimeType: "application/pdf",
Extension: "pdf",
Type: "attachment",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
@@ -104,73 +105,75 @@ func TestServeFileByIDAccessControl(t *testing.T) {
dbConn.Create(&avatarFile)
dbConn.Create(&attachmentFile)
// Set up router
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(response.ErrorHandlerMiddleware())
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("test_session", store))
r.Use(sessions.Sessions("wavelet_session_id", store))
r.GET("/f/:id", ServeFileByID)
t.Run("whitelisted file type (avatar) accessed without authentication", func(t *testing.T) {
t.Run("public access allowed for whitelist type (avatar)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
t.Fatalf("expected status 200 for public file, got %d", w.Code)
}
if w.Body.String() != "image" {
t.Errorf("expected 'image', got %q", w.Body.String())
t.Fatalf("expected body 'image', got '%s'", w.Body.String())
}
})
t.Run("non-whitelisted file type (attachment) accessed without authentication returns 401", func(t *testing.T) {
t.Run("public access rejected for non-whitelist type (attachment)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8002", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d. Body: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if body["error_msg"] != appshared.UnAuthorized {
t.Errorf("expected error_msg %q, got %v", appshared.UnAuthorized, body["error_msg"])
t.Fatalf("expected status 401 for private file without auth, got %d", w.Code)
}
})
t.Run("non-whitelisted file type (attachment) accessed with valid token succeeds", func(t *testing.T) {
t.Run("authenticated access allowed for non-whitelist type (attachment)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8002", nil)
req.Header.Set("X-Access-Token", tokenStr)
req.Header.Set("Authorization", "Bearer "+tokenStr)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
t.Fatalf("expected status 200 for authenticated request, got %d", w.Code)
}
if w.Body.String() != "bytes" {
t.Errorf("expected 'bytes', got %q", w.Body.String())
t.Fatalf("expected body 'bytes', got '%s'", w.Body.String())
}
})
t.Run("accessing non-existent file returns 404", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/9999", nil)
t.Run("non-existent file returns 404", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/99999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
t.Fatalf("expected status 404 for non-existent file, got %d", w.Code)
}
})
t.Run("invalid id format returns 400", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/invalid-id", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for invalid ID, got %d", w.Code)
}
})
}
func TestImageCompression(t *testing.T) {
func TestServeFileByIDImageCompression(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
cache.ResetAccessCaches()
tempDir := t.TempDir()
configureLocalStorageRoot(t, dbConn, tempDir)
@@ -187,12 +190,12 @@ func TestImageCompression(t *testing.T) {
}()
// Create test user
user := model.User{
user := contracts.UserDTO{
ID: 555,
Username: "compress_tester",
IsActive: true,
}
dbConn.Create(&user)
dbConn.Table("w_users").Create(&user)
// Create a 1x1 pixel PNG image
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
@@ -208,7 +211,7 @@ func TestImageCompression(t *testing.T) {
}
// Save upload record to DB
uploadRecord := model.Upload{
uploadRecord := models.Upload{
ID: 3001,
UserID: user.ID,
FileName: "test_image.png",
@@ -217,7 +220,7 @@ func TestImageCompression(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "avatar", // Whitelisted by default
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
AccessMode: 1,
}
dbConn.Create(&uploadRecord)
@@ -239,53 +242,15 @@ func TestImageCompression(t *testing.T) {
if w.Header().Get("Content-Type") != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type"))
}
if len(w.Body.Bytes()) != pngBuf.Len() {
t.Errorf("expected body size %d, got %d", pngBuf.Len(), len(w.Body.Bytes()))
if w.Header().Get("X-Cache") != "" {
t.Errorf("expected no X-Cache header for original file, got %s", w.Header().Get("X-Cache"))
}
if w.Header().Get("ETag") == "" {
t.Errorf("expected ETag header for original file")
}
})
t.Run("serve compressed WebP file with medium quality", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
// Content-Type should be image/webp
if w.Header().Get("Content-Type") != "image/webp" {
t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type"))
}
cacheKey := ImageCompressionCacheKey(&uploadRecord, shared.ImageQualityMedium)
cachedBytes, err := cache.Get(cacheKey)
if err != nil {
t.Fatalf("disk cache Get(%q) returned error: %v", cacheKey, err)
}
if !bytes.Equal(cachedBytes, w.Body.Bytes()) {
t.Errorf("cached compressed image differs from response")
}
if err := os.Remove(filePath); err != nil {
t.Fatalf("failed to remove source image before cache-hit request: %v", err)
}
t.Cleanup(func() {
if err := os.WriteFile(filePath, pngBuf.Bytes(), 0644); err != nil {
t.Errorf("failed to restore source image: %v", err)
}
})
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req)
if w2.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w2.Code)
}
if !bytes.Equal(w2.Body.Bytes(), cachedBytes) {
t.Errorf("cache-hit response differs from cached compressed image")
}
})
t.Run("serve compressed WebP file and check cache headers and 304 Not Modified", func(t *testing.T) {
t.Run("first request with quality=medium produces cache MISS and converts to WebP", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -293,29 +258,37 @@ func TestImageCompression(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
etag := w.Header().Get("ETag")
if etag == "" {
t.Error("expected ETag header, got empty")
if w.Header().Get("Content-Type") != "image/webp" {
t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type"))
}
cacheControl := w.Header().Get("Cache-Control")
if cacheControl != "public, max-age=31536000" {
t.Errorf("expected Cache-Control 'public, max-age=31536000', got %q", cacheControl)
if w.Header().Get("X-Cache") != "MISS" {
t.Errorf("expected X-Cache MISS on first compress request, got %s", w.Header().Get("X-Cache"))
}
// Perform conditional GET request
reqCond, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
reqCond.Header.Set("If-None-Match", etag)
wCond := httptest.NewRecorder()
r.ServeHTTP(wCond, reqCond)
if wCond.Code != http.StatusNotModified {
t.Errorf("expected status 304, got %d", wCond.Code)
if w.Header().Get("ETag") == "" {
t.Errorf("expected ETag header")
}
if len(w.Body.Bytes()) == 0 {
t.Errorf("expected non-empty body")
}
})
t.Run("serve original file with origin quality", func(t *testing.T) {
t.Run("second request with quality=medium produces cache HIT", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/webp" {
t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type"))
}
if w.Header().Get("X-Cache") != "HIT" {
t.Errorf("expected X-Cache HIT on second compress request, got %s", w.Header().Get("X-Cache"))
}
})
t.Run("request with quality=origin behaves like original request", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=origin", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -326,8 +299,32 @@ func TestImageCompression(t *testing.T) {
if w.Header().Get("Content-Type") != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type"))
}
if !bytes.Equal(w.Body.Bytes(), pngBuf.Bytes()) {
t.Errorf("origin-quality response differs from original image")
if w.Header().Get("X-Cache") != "" {
t.Errorf("expected no X-Cache header for origin quality, got %s", w.Header().Get("X-Cache"))
}
})
t.Run("conditional GET with matching If-None-Match returns 304", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
etag := w.Header().Get("ETag")
if etag == "" {
t.Fatalf("expected ETag header from initial request")
}
// Second request with If-None-Match
req2, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
req2.Header.Set("If-None-Match", etag)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusNotModified {
t.Fatalf("expected status 304 Not Modified, got %d", w2.Code)
}
if w2.Body.Len() != 0 {
t.Errorf("expected empty body on 304 response, got %d bytes", w2.Body.Len())
}
})
}
@@ -338,18 +335,20 @@ func TestNormalizeImageQuality(t *testing.T) {
quality string
want string
}{
{name: shared.ImageQualityLow, quality: shared.ImageQualityLow, want: shared.ImageQualityLow},
{name: shared.ImageQualityMedium, quality: shared.ImageQualityMedium, want: shared.ImageQualityMedium},
{name: shared.ImageQualityHigh, quality: shared.ImageQualityHigh, want: shared.ImageQualityHigh},
{name: "origin", quality: "origin", want: "origin"},
{name: "uppercase", quality: "LOW", want: shared.ImageQualityLow},
{name: "empty", quality: "", want: "origin"},
{name: "invalid", quality: "maximum", want: "origin"},
{name: "empty quality returns origin", quality: "", want: shared.ImageQualityOrigin},
{name: "origin returns origin", quality: "origin", want: shared.ImageQualityOrigin},
{name: "ORIGIN case-insensitive returns origin", quality: "ORIGIN", want: shared.ImageQualityOrigin},
{name: "low returns low", quality: "low", want: shared.ImageQualityLow},
{name: "LOW returns low", quality: "LOW", want: shared.ImageQualityLow},
{name: "medium returns medium", quality: "medium", want: shared.ImageQualityMedium},
{name: "high returns high", quality: "high", want: shared.ImageQualityHigh},
{name: "unknown quality defaults to origin", quality: "ultra_hd", want: shared.ImageQualityOrigin},
{name: "whitespace padded quality is trimmed", quality: " medium ", want: shared.ImageQualityMedium},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := util.NormalizeImageQuality(tt.quality); got != tt.want {
if got := uploadutil.NormalizeImageQuality(tt.quality); got != tt.want {
t.Errorf("NormalizeImageQuality(%q) = %q, want %q", tt.quality, got, tt.want)
}
})
@@ -357,8 +356,11 @@ func TestNormalizeImageQuality(t *testing.T) {
}
func configureLocalStorageRoot(t *testing.T, dbConn *gorm.DB, tempDir string) {
var sc model.SystemConfig
if err := dbConn.Where("key = ?", model.ConfigKeyStorageConfig).First(&sc).Error; err != nil {
var sc struct {
Key string
Value string
}
if err := dbConn.Table("w_system_configs").Where("key = ?", "storage_config").First(&sc).Error; err != nil {
t.Fatalf("failed to find storage config: %v", err)
}
var cfg objectstore.Config
@@ -371,10 +373,8 @@ func configureLocalStorageRoot(t *testing.T, dbConn *gorm.DB, tempDir string) {
t.Fatalf("failed to marshal storage config: %v", err)
}
sc.Value = string(newVal)
if err := dbConn.Save(&sc).Error; err != nil {
if err := dbConn.Table("w_system_configs").Where("key = ?", "storage_config").Update("value", sc.Value).Error; err != nil {
t.Fatalf("failed to save storage config: %v", err)
}
_ = db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, sc.Key, &sc)
repository.ResetSystemConfigRAMCacheForTest()
objectstore.ResetCache()
}
@@ -7,15 +7,16 @@ import (
"net/http"
"strconv"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/gin-gonic/gin"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/response"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/repository"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/gin-gonic/gin"
)
type listFilesRequest struct {
@@ -28,10 +29,10 @@ type listFilesRequest struct {
}
type listFilesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []model.Upload `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []models.Upload `json:"items"`
}
// ListFiles 获取系统上传的文件列表
@@ -44,12 +45,11 @@ type listFilesResponse struct {
// @Param keyword query string false "文件名关键词(模糊匹配)"
// @Param type query string false "业务分类过滤"
// @Param extension query string false "扩展名过滤"
// @Param user_id query uint64 false "上传用户 ID"
// @Param user_id query int false "上传用户 ID 过滤"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=listFilesResponse} "查询成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/admin/uploads [get]
// @Failure 400 {object} response.Any "参数错误"
// @Router /api/v1/admin/uploads/files [get]
func ListFiles(c *gin.Context) {
ctx := c.Request.Context()
@@ -86,17 +86,16 @@ func ListFiles(c *gin.Context) {
}))
}
// DeleteFile 软删除文件记录
// DeleteFile 软删除指定的文件记录
// @Summary 删除文件
// @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象
// @Description 将指定 ID 的文件状态置为 deleted(软删除)
// @Tags admin
// @Produce json
// @Param id path string true "文件 ID"
// @Security SessionCookie
// @Success 200 {object} response.Any "删除成功"
// @Failure 403 {object} response.Any "无权操作"
// @Failure 404 {object} response.Any "文件不存在"
// @Router /api/v1/admin/uploads/{id} [delete]
// @Router /api/v1/admin/uploads/files/{id} [delete]
func DeleteFile(c *gin.Context) {
ctx := c.Request.Context()
if uploadstorage.ReadOnly(ctx) {
@@ -121,21 +120,19 @@ func DeleteFile(c *gin.Context) {
c.JSON(http.StatusOK, response.OKNil())
}
// GetDistinctUploadTypes 获取数据库中所有已存在的文件业务类型
// @Summary 获取文件业务类型列表
// @Description 返回数据库中所有已上传文件实际拥有的业务类型列表
// GetDistinctUploadTypes 获取所有已存在的文件业务分类列表
// @Summary 获取业务分类列表
// @Description 查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等)
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]string} "业务类型列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Success 200 {object} response.Any{data=[]string} "查询成功"
// @Router /api/v1/admin/uploads/types [get]
func GetDistinctUploadTypes(c *gin.Context) {
types, err := listDistinctUploadTypes(c.Request.Context())
ctx := c.Request.Context()
types, err := listDistinctUploadTypes(ctx)
if err != nil {
response.AbortInternal(c, err.Error())
response.AbortBadRequest(c, shared.ErrQueryTypeListFailed)
return
}
c.JSON(http.StatusOK, response.OK(types))
@@ -150,10 +147,10 @@ type listMyFilesRequest struct {
}
type listMyFilesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []model.Upload `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []models.Upload `json:"items"`
}
// ListMyFiles 获取当前用户上传的文件列表
@@ -171,7 +168,7 @@ type listMyFilesResponse struct {
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/upload/my [get]
func ListMyFiles(c *gin.Context) {
currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey)
currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey)
ctx := c.Request.Context()
var req listMyFilesRequest
@@ -218,7 +215,7 @@ func ListMyFiles(c *gin.Context) {
// @Failure 404 {object} response.Any "文件不存在"
// @Router /api/v1/upload/{id} [delete]
func DeleteMyFile(c *gin.Context) {
currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey)
currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey)
ctx := c.Request.Context()
if uploadstorage.ReadOnly(ctx) {
response.AbortConflict(c, shared.ErrStorageReadOnly)
@@ -260,12 +257,12 @@ type updateMyFileRequest struct {
// @Param id path string true "文件 ID"
// @Param request body updateMyFileRequest true "更新字段"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.Upload} "更新成功"
// @Success 200 {object} response.Any{data=models.Upload} "更新成功"
// @Failure 403 {object} response.Any "无权操作"
// @Failure 404 {object} response.Any "文件不存在"
// @Router /api/v1/upload/{id} [put]
func UpdateMyFile(c *gin.Context) {
currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey)
currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey)
ctx := c.Request.Context()
if uploadstorage.ReadOnly(ctx) {
response.AbortConflict(c, shared.ErrStorageReadOnly)
@@ -284,7 +281,7 @@ func UpdateMyFile(c *gin.Context) {
return
}
upload, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req))
updated, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req))
if err != nil {
if isRecordNotFound(err) {
response.AbortNotFound(c, "文件记录未找到")
@@ -294,9 +291,9 @@ func UpdateMyFile(c *gin.Context) {
response.AbortForbidden(c, "无权操作")
return
}
response.AbortBadRequest(c, "更新文件记录失败")
response.AbortBadRequest(c, shared.ErrUpdateFileFailed)
return
}
c.JSON(http.StatusOK, response.OK(upload))
c.JSON(http.StatusOK, response.OK(updated))
}
@@ -9,19 +9,21 @@ import (
"net/http/httptest"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-gonic/gin"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
)
func TestGetDistinctUploadTypes(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
user := model.User{ID: 2222, Username: "test_user_2"}
dbConn.Create(&user)
user := contracts.UserDTO{ID: 2222, Username: "test_user_2"}
dbConn.Table("w_users").Create(&user)
customUpload := model.Upload{
customUpload := models.Upload{
ID: 9001,
UserID: user.ID,
FileName: "custom.txt",
@@ -30,7 +32,7 @@ func TestGetDistinctUploadTypes(t *testing.T) {
MimeType: "text/plain",
Extension: "txt",
Type: "custom_type_xyz",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
}
dbConn.Create(&customUpload)
+20 -19
View File
@@ -8,26 +8,27 @@ import (
"errors"
"sort"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/repository"
)
func listUploadFiles(ctx context.Context, filter repository.UploadListFilter) (int64, []model.Upload, error) {
func listUploadFiles(ctx context.Context, filter repository.UploadListFilter) (int64, []models.Upload, error) {
return repository.ListUploads(ctx, filter)
}
func listMyUploadFiles(ctx context.Context, userID uint64, filter repository.UploadListFilter) (int64, []model.Upload, error) {
func listMyUploadFiles(ctx context.Context, userID uint64, filter repository.UploadListFilter) (int64, []models.Upload, error) {
filter.UserID = userID
return repository.ListUploads(ctx, filter)
}
func softDeleteUpload(ctx context.Context, uploadID uint64) (model.Upload, error) {
func softDeleteUpload(ctx context.Context, uploadID uint64) (models.Upload, error) {
return ingest.Remove(ctx, uploadID)
}
func softDeleteOwnedUpload(ctx context.Context, userID, uploadID uint64) (model.Upload, error) {
func softDeleteOwnedUpload(ctx context.Context, userID, uploadID uint64) (models.Upload, error) {
return ingest.RemoveOwned(ctx, userID, uploadID)
}
@@ -45,13 +46,13 @@ type updateMyUploadInput struct {
AccessMode *int
}
func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updateMyUploadInput) (model.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updateMyUploadInput) (models.Upload, error) {
u, err := repository.GetActiveUploadByID(ctx, uploadID)
if err != nil {
return model.Upload{}, err
return models.Upload{}, err
}
if upload.UserID != userID {
return model.Upload{}, ingest.ErrForbidden
if u.UserID != userID {
return models.Upload{}, ingest.ErrForbidden
}
updates := make(map[string]any)
@@ -61,23 +62,23 @@ func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updat
if input.AccessMode != nil {
updates["access_mode"] = *input.AccessMode
}
if err := repository.UpdateUpload(ctx, &upload, updates); err != nil {
return model.Upload{}, err
if err := repository.UpdateUpload(ctx, &u, updates); err != nil {
return models.Upload{}, err
}
if name, ok := updates["file_name"].(string); ok {
upload.FileName = name
u.FileName = name
}
if mode, ok := updates["access_mode"].(int); ok {
upload.AccessMode = mode
u.AccessMode = mode
}
return upload, nil
return u, nil
}
func listUploadsForBatchDownload(ctx context.Context, ids []uint64) ([]model.Upload, error) {
func listUploadsForBatchDownload(ctx context.Context, ids []uint64) ([]models.Upload, error) {
return repository.ListUploadsByIDs(ctx, ids)
}
func loadUploadStats(ctx context.Context) ([]model.UploadStat, error) {
func loadUploadStats(ctx context.Context) ([]models.UploadStat, error) {
return repository.ListUploadStats(ctx)
}
+8 -7
View File
@@ -22,13 +22,14 @@ import (
"strconv"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
appshared "github.com/Rain-kl/Wavelet/internal/shared"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/response"
appshared "github.com/Rain-kl/Wavelet/pkg/shared"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/util"
@@ -50,7 +51,7 @@ type batchDownloadRequest struct {
// @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)"
// @Param metadata formData string false "额外的 JSON 格式元数据"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.Upload} "上传成功"
// @Success 200 {object} response.Any{data=models.Upload} "上传成功"
// @Failure 400 {object} response.Any "请求参数错误或文件受限"
// @Failure 401 {object} response.Any "未登录"
// @Failure 500 {object} response.Any "内部错误"
@@ -63,7 +64,7 @@ func UploadFile(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, shared.MaxUploadSize)
currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey)
currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey)
ctx := c.Request.Context()
header, err := c.FormFile("file")
@@ -310,8 +311,8 @@ func resolveUploadAccessMode(c *gin.Context, uploadType string) (int, string) {
return accessMode, ""
}
func parseUploadMetadata(c *gin.Context, mimeType string) (model.UploadMetadata, string) {
var meta model.UploadMetadata
func parseUploadMetadata(c *gin.Context, mimeType string) (models.UploadMetadata, string) {
var meta models.UploadMetadata
metadataStr := c.DefaultPostForm("metadata", "")
if metadataStr != "" {
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
+45 -51
View File
@@ -19,15 +19,14 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/response"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
"github.com/gin-gonic/gin"
)
@@ -36,7 +35,7 @@ type testResponse struct {
Data json.RawMessage `json:"data"`
}
func setupTestRouter(authUser *model.User) *gin.Engine {
func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(response.ErrorHandlerMiddleware())
@@ -106,7 +105,7 @@ func TestUploadFile(t *testing.T) {
defer cleanup()
defer func() { _ = os.RemoveAll("uploads") }() // Clean up local files created during tests
authUser := &model.User{ID: 1001, Username: "test_user"}
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
// Mock Storage Client
@@ -175,12 +174,12 @@ func TestUploadFile(t *testing.T) {
}
// Verify database record
var uploadRecord model.Upload
var uploadRecord models.Upload
if err := json.Unmarshal(resp.Data, &uploadRecord); err != nil {
t.Fatalf("failed to unmarshal upload record: %v", err)
}
var dbRecord model.Upload
var dbRecord models.Upload
if err := dbConn.First(&dbRecord, uploadRecord.ID).Error; err != nil {
t.Fatalf("failed to retrieve database record: %v", err)
}
@@ -258,7 +257,7 @@ func TestUploadFile(t *testing.T) {
t.Fatalf("second upload was unsuccessful: %s", resp2.ErrorMsg)
}
var uploadRecord2 model.Upload
var uploadRecord2 models.Upload
if err := json.Unmarshal(resp2.Data, &uploadRecord2); err != nil {
t.Fatalf("failed to unmarshal second upload record: %v", err)
}
@@ -269,7 +268,7 @@ func TestUploadFile(t *testing.T) {
}
// Check if database contains both records sharing the same FilePath
var records []model.Upload
var records []models.Upload
dbConn.Where("hash = ?", uploadRecord2.Hash).Find(&records)
if len(records) != 2 {
t.Errorf("expected 2 database records sharing the same hash, got %d", len(records))
@@ -289,12 +288,7 @@ func TestUploadFile(t *testing.T) {
objectstore.IsEnabledFunc = func() bool { return false }
// Seed allowed extensions configuration to allow txt files
var sc model.SystemConfig
dbConn.Where("key = ?", model.ConfigKeyUploadAllowedExtensions).First(&sc)
sc.Value = "jpg,png,webp,txt"
dbConn.Save(&sc)
_ = db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, sc.Key, &sc)
repository.ResetSystemConfigRAMCacheForTest()
dbConn.Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Update("value", "jpg,png,webp,txt")
contentType, body := createMultipartRequest(t, "file", "doc.txt", []byte("hello world generic document file"), map[string]string{
"type": "document",
@@ -316,7 +310,7 @@ func TestUploadFile(t *testing.T) {
t.Fatalf("local upload failed: %s", resp.ErrorMsg)
}
var localRecord model.Upload
var localRecord models.Upload
if err := json.Unmarshal(resp.Data, &localRecord); err != nil {
t.Fatalf("failed to unmarshal local upload record: %v", err)
}
@@ -338,11 +332,11 @@ func TestDownloadFile(t *testing.T) {
defer cleanup()
defer func() { _ = os.RemoveAll("uploads") }()
authUser := &model.User{ID: 1001, Username: "test_user"}
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
// Seed upload records in DB
localUpload := model.Upload{
localUpload := models.Upload{
ID: 2001,
UserID: 1001,
FileName: "中文文件名.txt",
@@ -350,7 +344,7 @@ func TestDownloadFile(t *testing.T) {
FileSize: 12,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
}
// Create local file
@@ -405,10 +399,10 @@ func TestListFiles(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
authUser := &model.User{ID: 1001, Username: "test_user"}
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
uploads := []model.Upload{
uploads := []models.Upload{
{
ID: 2101,
UserID: authUser.ID,
@@ -417,7 +411,7 @@ func TestListFiles(t *testing.T) {
FileSize: 10,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 2102,
@@ -427,7 +421,7 @@ func TestListFiles(t *testing.T) {
FileSize: 20,
MimeType: "image/png",
Extension: "png",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 2103,
@@ -437,7 +431,7 @@ func TestListFiles(t *testing.T) {
FileSize: 30,
MimeType: "text/markdown",
Extension: "md",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 2104,
@@ -447,7 +441,7 @@ func TestListFiles(t *testing.T) {
FileSize: 40,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
}
for i := range uploads {
@@ -546,7 +540,7 @@ func TestBatchDownloadFiles(t *testing.T) {
defer cleanup()
defer func() { _ = os.RemoveAll("uploads") }()
authUser := &model.User{ID: 1001, Username: "test_user"}
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
// Create and write files locally
@@ -560,7 +554,7 @@ func TestBatchDownloadFiles(t *testing.T) {
_ = os.WriteFile("uploads/f3.txt", []byte("duplicate name file content"), 0644)
// Seed upload records. Note f2 and f3 have the same FileName "file_a.txt" to trigger name collision resolution.
uploads := []model.Upload{
uploads := []models.Upload{
{
ID: 3001,
UserID: 1001,
@@ -569,7 +563,7 @@ func TestBatchDownloadFiles(t *testing.T) {
FileSize: 13,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 3002,
@@ -579,7 +573,7 @@ func TestBatchDownloadFiles(t *testing.T) {
FileSize: 13,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 3003,
@@ -589,7 +583,7 @@ func TestBatchDownloadFiles(t *testing.T) {
FileSize: 28,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
}
@@ -658,8 +652,8 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
defer cleanup()
defer func() { _ = os.RemoveAll("uploads") }()
user1 := &model.User{ID: 1001, Username: "user1"}
user2 := &model.User{ID: 1002, Username: "user2"}
user1 := &contracts.UserDTO{ID: 1001, Username: "user1"}
user2 := &contracts.UserDTO{ID: 1002, Username: "user2"}
// Seed user1
if err := dbConn.Create(user1).Error; err != nil {
@@ -689,7 +683,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
t.Logf("Raw upload response: %s", w.Body.String())
var resp1 testResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp1)
var upload1 model.Upload
var upload1 models.Upload
_ = json.Unmarshal(resp1.Data, &upload1)
if upload1.AccessMode != 0 {
@@ -707,7 +701,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
router.ServeHTTP(w2, req2)
var resp2 testResponse
_ = json.Unmarshal(w2.Body.Bytes(), &resp2)
var upload2 model.Upload
var upload2 models.Upload
_ = json.Unmarshal(resp2.Data, &upload2)
if upload2.AccessMode != 1 {
@@ -744,11 +738,11 @@ func TestGetFileStats(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
authUser := &model.User{ID: 1001, Username: "test_user"}
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
// Insert some dummy uploads
uploads := []model.Upload{
uploads := []models.Upload{
{
ID: 3101,
UserID: authUser.ID,
@@ -758,7 +752,7 @@ func TestGetFileStats(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "generic",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
},
{
@@ -770,7 +764,7 @@ func TestGetFileStats(t *testing.T) {
MimeType: "video/mp4",
Extension: "mp4",
Type: "generic",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now().AddDate(0, 0, -2), // 2 days ago
},
{
@@ -782,7 +776,7 @@ func TestGetFileStats(t *testing.T) {
MimeType: "application/pdf",
Extension: "pdf",
Type: "avatar", // different type
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now().AddDate(0, 0, -10), // older than 7 days
},
}
@@ -854,8 +848,8 @@ func TestUserUploadManagement(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
user1 := &model.User{ID: 1001, Username: "user1"}
user2 := &model.User{ID: 1002, Username: "user2"}
user1 := &contracts.UserDTO{ID: 1001, Username: "user1"}
user2 := &contracts.UserDTO{ID: 1002, Username: "user2"}
_ = dbConn.Create(user1)
_ = dbConn.Create(user2)
@@ -864,7 +858,7 @@ func TestUserUploadManagement(t *testing.T) {
router2 := setupTestRouter(user2)
// Seed upload records
upload1 := model.Upload{
upload1 := models.Upload{
ID: 4001,
UserID: 1001,
FileName: "user1-file.txt",
@@ -872,10 +866,10 @@ func TestUserUploadManagement(t *testing.T) {
FileSize: 100,
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
upload2 := model.Upload{
upload2 := models.Upload{
ID: 4002,
UserID: 1002,
FileName: "user2-file.png",
@@ -883,7 +877,7 @@ func TestUserUploadManagement(t *testing.T) {
FileSize: 200,
MimeType: "image/png",
Extension: "png",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
@@ -927,7 +921,7 @@ func TestUserUploadManagement(t *testing.T) {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var updated model.Upload
var updated models.Upload
dbConn.First(&updated, 4001)
if updated.FileName != "renamed.txt" {
t.Errorf("expected file name renamed.txt, got %s", updated.FileName)
@@ -970,9 +964,9 @@ func TestUserUploadManagement(t *testing.T) {
t.Fatalf("expected status 200, got %d", w.Code)
}
var deleted model.Upload
var deleted models.Upload
dbConn.First(&deleted, 4001)
if deleted.Status != model.UploadStatusDeleted {
if deleted.Status != models.UploadStatusDeleted {
t.Errorf("expected status deleted, got %s", deleted.Status)
}
})
+7 -7
View File
@@ -7,10 +7,10 @@ import (
"net/http"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
"github.com/gin-gonic/gin"
"github.com/Rain-kl/Wavelet/pkg/response"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
)
type trendItem struct {
@@ -79,22 +79,22 @@ func GetFileStats(c *gin.Context) {
for _, stat := range stats {
switch stat.Dimension {
case model.UploadStatDimensionTotal:
case shared.UploadStatDimensionTotal:
totalCount = stat.FileCount
totalSize = stat.FileSize
case model.UploadStatDimensionType:
case shared.UploadStatDimensionType:
types = append(types, distributionItem{
Name: stat.StatKey,
Count: stat.FileCount,
Size: stat.FileSize,
})
case model.UploadStatDimensionCategory:
case shared.UploadStatDimensionCategory:
if item, ok := categoryMap[stat.StatKey]; ok {
item.Count = stat.FileCount
item.Size = stat.FileSize
categoryMap[stat.StatKey] = item
}
case model.UploadStatDimensionTrend:
case shared.UploadStatDimensionTrend:
if _, ok := trendCountMap[stat.StatKey]; ok {
trendCountMap[stat.StatKey] = stat.FileCount
trendSizeMap[stat.StatKey] = stat.FileSize
+29 -16
View File
@@ -5,22 +5,23 @@ package ingest
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/persistence/idgen"
uploadcache "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/repository"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
"gorm.io/gorm"
)
@@ -33,7 +34,7 @@ func normalizeRequest(req *Request) {
req.Type = "generic"
}
if req.Status == "" {
req.Status = model.UploadStatusUsed
req.Status = models.UploadStatusUsed
}
}
@@ -48,18 +49,30 @@ func resolveAccessMode(uploadType string, explicit *int) int {
}
func validateAllowedExtension(ctx context.Context, ext string) error {
sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUploadAllowedExtensions)
var val string
err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Pluck("value", &val).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
logger.WarnF(ctx, "failed to query upload_allowed_extensions: %v", err)
return nil
}
if sc.Value == "" {
if val == "" {
return nil
}
allowedExts := strings.Split(strings.ToLower(sc.Value), ",")
var list []string
if err := json.Unmarshal([]byte(val), &list); err == nil {
for _, allowedExt := range list {
if strings.EqualFold(strings.TrimSpace(allowedExt), ext) {
return nil
}
}
return errors.New(shared.ErrUnsupportedFormat)
}
allowedExts := strings.Split(strings.ToLower(val), ",")
for _, allowedExt := range allowedExts {
if strings.TrimSpace(allowedExt) == ext {
return nil
@@ -79,7 +92,7 @@ func buildObjectKey(req Request, id uint64) string {
return defaultObjectKey(id, req.Extension)
}
func storeObject(ctx context.Context, objectKey string, reader io.Reader, size int64, mimeType string, meta *model.UploadMetadata) (string, error) {
func storeObject(ctx context.Context, objectKey string, reader io.Reader, size int64, mimeType string, meta *models.UploadMetadata) (string, error) {
if uploadstorage.ReadOnly(ctx) {
return "", ErrStorageReadOnly
}
@@ -100,7 +113,7 @@ func storeObject(ctx context.Context, objectKey string, reader io.Reader, size i
return result.Key, nil
}
func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey string) error {
func persistUploadRecord(ctx context.Context, upload *models.Upload, objectKey string) error {
if err := createUploadWithStats(ctx, upload); err != nil {
_, backend, backendErr := objectstore.Active(ctx)
if backendErr == nil {
@@ -114,7 +127,7 @@ func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey st
return nil
}
func createUploadWithStats(ctx context.Context, upload *model.Upload) error {
func createUploadWithStats(ctx context.Context, upload *models.Upload) error {
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := repository.CreateUploadTx(tx, upload); err != nil {
return err
@@ -123,9 +136,9 @@ func createUploadWithStats(ctx context.Context, upload *model.Upload) error {
})
}
func createDedupRecord(ctx context.Context, existing model.Upload, req Request) (Result, error) {
func createDedupRecord(ctx context.Context, existing models.Upload, req Request) (Result, error) {
accessMode := resolveAccessMode(req.Type, req.AccessMode)
newUpload := model.Upload{
newUpload := models.Upload{
ID: idgen.NextUint64ID(),
UserID: req.UserID,
FileName: req.FileName,
@@ -172,7 +185,7 @@ func createNewUpload(ctx context.Context, req Request) (Result, error) {
}
accessMode := resolveAccessMode(req.Type, req.AccessMode)
upload := model.Upload{
upload := models.Upload{
ID: id,
UserID: req.UserID,
FileName: req.FileName,
+3 -3
View File
@@ -7,8 +7,8 @@ import (
"context"
"errors"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/repository"
"gorm.io/gorm"
)
@@ -36,7 +36,7 @@ func Ingest(ctx context.Context, req Request) (Result, error) {
}
// FindByHash returns a reusable active upload with the same hash and size.
func FindByHash(ctx context.Context, hash string, size int64) (model.Upload, error) {
func FindByHash(ctx context.Context, hash string, size int64) (models.Upload, error) {
return repository.FindReusableUploadByHash(ctx, hash, size)
}
+13 -13
View File
@@ -13,10 +13,10 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
)
func TestIngestPolicyCreateIncrementsStats(t *testing.T) {
@@ -67,7 +67,7 @@ func TestIngestPolicyResolveExistingSkipsStatsOnHit(t *testing.T) {
hash := sha256.Sum256(content)
hashStr := hex.EncodeToString(hash[:])
existing := model.Upload{
existing := models.Upload{
ID: 88001,
UserID: 42,
FileName: "existing.png",
@@ -77,7 +77,7 @@ func TestIngestPolicyResolveExistingSkipsStatsOnHit(t *testing.T) {
Extension: "png",
Hash: hashStr,
Type: "pixez_mirror",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := dbConn.Create(&existing).Error; err != nil {
@@ -175,7 +175,7 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) {
}
var count int64
if err := dbConn.Model(&model.Upload{}).Where("hash = ?", hashStr).Count(&count).Error; err != nil {
if err := dbConn.Model(&models.Upload{}).Where("hash = ?", hashStr).Count(&count).Error; err != nil {
t.Fatalf("count uploads failed: %v", err)
}
if count != 2 {
@@ -188,7 +188,7 @@ func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) {
defer cleanup()
ctx := context.Background()
existing := model.Upload{
existing := models.Upload{
ID: 99001,
UserID: 1001,
FileName: "existing.png",
@@ -197,14 +197,14 @@ func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "generic",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := dbConn.Create(&existing).Error; err != nil {
t.Fatalf("seed upload failed: %v", err)
}
duplicate := &model.Upload{
duplicate := &models.Upload{
ID: existing.ID,
UserID: 1002,
FileName: "duplicate.png",
@@ -213,7 +213,7 @@ func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) {
MimeType: "image/png",
Extension: "png",
Type: "generic",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := createUploadWithStats(ctx, duplicate); err == nil {
@@ -275,8 +275,8 @@ type totalStatsSnapshot struct {
}
func loadTotalStats(ctx context.Context) (totalStatsSnapshot, error) {
var rows []model.UploadStat
if err := db.DB(ctx).Where("dimension = ?", model.UploadStatDimensionTotal).Find(&rows).Error; err != nil {
var rows []models.UploadStat
if err := db.DB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil {
return totalStatsSnapshot{}, err
}
if len(rows) == 0 {
+13 -13
View File
@@ -6,44 +6,44 @@ package ingest
import (
"context"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/pkg/persistence"
uploadcache "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/repository"
uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats"
"gorm.io/gorm"
)
// Remove soft-deletes an upload and decrements incremental stats.
func Remove(ctx context.Context, uploadID uint64) (model.Upload, error) {
func Remove(ctx context.Context, uploadID uint64) (models.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
if err != nil {
return model.Upload{}, err
return models.Upload{}, err
}
if err := softDeleteUploadWithStats(ctx, &upload); err != nil {
return model.Upload{}, err
return models.Upload{}, err
}
upload.Status = model.UploadStatusDeleted
upload.Status = models.UploadStatusDeleted
return upload, nil
}
// RemoveOwned soft-deletes an upload owned by userID and decrements incremental stats.
func RemoveOwned(ctx context.Context, userID, uploadID uint64) (model.Upload, error) {
func RemoveOwned(ctx context.Context, userID, uploadID uint64) (models.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
if err != nil {
return model.Upload{}, err
return models.Upload{}, err
}
if upload.UserID != userID {
return model.Upload{}, ErrForbidden
return models.Upload{}, ErrForbidden
}
if err := softDeleteUploadWithStats(ctx, &upload); err != nil {
return model.Upload{}, err
return models.Upload{}, err
}
upload.Status = model.UploadStatusDeleted
upload.Status = models.UploadStatusDeleted
return upload, nil
}
func softDeleteUploadWithStats(ctx context.Context, upload *model.Upload) error {
func softDeleteUploadWithStats(ctx context.Context, upload *models.Upload) error {
statsSnapshot := *upload
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := repository.SoftDeleteUploadTx(tx, upload); err != nil {
+4 -4
View File
@@ -7,7 +7,7 @@ package ingest
import (
"io"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
)
// Policy controls how ingest handles hash collisions and record creation.
@@ -33,7 +33,7 @@ type Request struct {
Type string
AccessMode *int
Status model.UploadStatus
Status models.UploadStatus
Reader io.Reader
Size int64
@@ -42,7 +42,7 @@ type Request struct {
Extension string
Hash string
Metadata model.UploadMetadata
Metadata models.UploadMetadata
Policy Policy
ObjectKeyFn ObjectKeyFn
@@ -53,7 +53,7 @@ type Request struct {
// Result reports the outcome of an ingest operation.
type Result struct {
Upload model.Upload
Upload models.Upload
Created bool
Stored bool
Resolved bool
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package upload 提供上传域的门面与类型重导出。
package upload
import (
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
)
// UploadStatus 上传状态类型别名
//
//nolint:revive
type UploadStatus = models.UploadStatus
// UploadMetadata 上传元数据类型别名
//
//nolint:revive
type UploadMetadata = models.UploadMetadata
// Upload 上传实体类型别名
type Upload = models.Upload
// UploadStat 上传统计实体类型别名
//
//nolint:revive
type UploadStat = models.UploadStat
// 上传状态常量别名
const (
UploadStatusPending = models.UploadStatusPending
UploadStatusUsed = models.UploadStatusUsed
UploadStatusDeleted = models.UploadStatusDeleted
)
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package models 提供上传域核心数据模型。
package models
import (
"time"
)
// UploadStatus 上传状态
type UploadStatus string
// 上传状态
const (
UploadStatusPending UploadStatus = "pending" // 待使用
UploadStatusUsed UploadStatus = "used" // 已使用
UploadStatusDeleted UploadStatus = "deleted" // 已删除
)
// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据
type UploadMetadata struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Duration float64 `json:"duration,omitempty"`
OriginalMime string `json:"original_mime,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
Bucket string `json:"bucket,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// Upload 上传文件记录
type Upload struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
UserID uint64 `json:"user_id,string" gorm:"index;not null"`
FileName string `json:"file_name" gorm:"size:255;not null"`
FilePath string `json:"file_path" gorm:"size:500;not null;index"`
FileSize int64 `json:"file_size" gorm:"not null"`
MimeType string `json:"mime_type" gorm:"size:100;not null"`
Extension string `json:"extension" gorm:"size:50;not null"`
Hash string `json:"hash" gorm:"size:64;index"`
Type string `json:"type" gorm:"column:type;size:50;not null;index"`
Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"`
AccessMode int `json:"access_mode" gorm:"column:access_mode;not null;default:0"`
Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (Upload) TableName() string {
return "w_uploads"
}
// Upload stats dimension keys stored in w_upload_stats.dimension.
const (
UploadStatDimensionTotal = "total"
UploadStatDimensionType = "type"
UploadStatDimensionCategory = "category"
UploadStatDimensionTrend = "trend"
)
// UploadStat 聚合统计记录
type UploadStat struct {
Dimension string `json:"dimension" gorm:"primaryKey;size:32;not null"`
StatKey string `json:"stat_key" gorm:"primaryKey;size:64;not null;default:''"`
FileCount int64 `json:"file_count" gorm:"not null;default:0"`
FileSize int64 `json:"file_size" gorm:"not null;default:0"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (UploadStat) TableName() string {
return "w_upload_stats"
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package upload
import (
"context"
"strings"
"gorm.io/gorm"
db "github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/persistence/idgen"
"github.com/Rain-kl/Wavelet/pkg/util"
)
// UploadListFilter filters paginated upload queries.
//
//nolint:revive
type UploadListFilter struct {
UserID uint64
Keyword string
Type string
Extension string
Page int
PageSize int
}
// ListUploads returns paginated upload records matching the filter.
func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []Upload, error) {
query := db.DB(ctx).Model(&Upload{}).
Where("status != ?", UploadStatusDeleted)
if filter.UserID != 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Keyword != "" {
query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%")
}
if filter.Type != "" {
query = query.Where("type = ?", filter.Type)
}
if filter.Extension != "" {
query = query.Where("extension = ?", strings.ToLower(filter.Extension))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var items []Upload
offset := (filter.Page - 1) * filter.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
// GetActiveUploadByID loads a non-deleted upload by ID.
func GetActiveUploadByID(ctx context.Context, id uint64) (Upload, error) {
var upload Upload
if err := db.DB(ctx).Where("id = ? AND status != ?", id, UploadStatusDeleted).First(&upload).Error; err != nil {
return Upload{}, err
}
return upload, nil
}
// SoftDeleteUpload marks an upload as deleted.
// External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this.
func SoftDeleteUpload(ctx context.Context, upload *Upload) error {
return SoftDeleteUploadTx(db.DB(ctx), upload)
}
// SoftDeleteUploadTx marks an upload as deleted within an existing transaction.
func SoftDeleteUploadTx(tx *gorm.DB, upload *Upload) error {
return tx.Model(upload).Update("status", UploadStatusDeleted).Error
}
// UpdateUpload applies partial field updates to an upload record.
func UpdateUpload(ctx context.Context, upload *Upload, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
return db.DB(ctx).Model(upload).Updates(updates).Error
}
// ListDistinctUploadTypes returns all distinct non-empty upload business types.
func ListDistinctUploadTypes(ctx context.Context) ([]string, error) {
var types []string
if err := db.DB(ctx).Model(&Upload{}).
Where("type IS NOT NULL AND type != ''").
Distinct().
Pluck("type", &types).Error; err != nil {
return nil, err
}
return types, nil
}
// FindReusableUploadByHash finds an existing upload with the same hash and size.
func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (Upload, error) {
var existing Upload
err := db.DB(ctx).
Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, UploadStatusPending, UploadStatusUsed).
First(&existing).Error
return existing, err
}
// CreateUpload persists a new upload record.
func CreateUpload(ctx context.Context, upload *Upload) error {
return CreateUploadTx(db.DB(ctx), upload)
}
// CreateUploadTx persists a new upload record within an existing transaction.
func CreateUploadTx(tx *gorm.DB, upload *Upload) error {
if upload.ID == 0 {
upload.ID = idgen.NextUint64ID()
}
return tx.Create(upload).Error
}
// ListUploadsByIDs returns active uploads matching the given IDs.
func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]Upload, error) {
var uploads []Upload
if err := db.DB(ctx).
Where("id IN ? AND status IN (?, ?)", ids, UploadStatusPending, UploadStatusUsed).
Find(&uploads).Error; err != nil {
return nil, err
}
return uploads, nil
}
// UploadQuery returns a scoped GORM query for uploads.
//
//nolint:revive
func UploadQuery(ctx context.Context) *gorm.DB {
return db.DB(ctx).Model(&Upload{})
}
// ListUploadStats returns all upload statistics rows.
func ListUploadStats(ctx context.Context) ([]UploadStat, error) {
var stats []UploadStat
if err := db.DB(ctx).Find(&stats).Error; err != nil {
return nil, err
}
return stats, nil
}
@@ -0,0 +1,140 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package repository 提供上传域数据库仓储层操作。
package repository
import (
"context"
"strings"
db "github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/util"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"gorm.io/gorm"
)
// UploadListFilter filters paginated upload queries.
type UploadListFilter struct {
UserID uint64
Keyword string
Type string
Extension string
Page int
PageSize int
}
// ListUploads returns paginated upload records matching the filter.
func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []models.Upload, error) {
query := db.DB(ctx).Model(&models.Upload{}).
Where("status != ?", models.UploadStatusDeleted)
if filter.UserID != 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Keyword != "" {
query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%")
}
if filter.Type != "" {
query = query.Where("type = ?", filter.Type)
}
if filter.Extension != "" {
query = query.Where("extension = ?", strings.ToLower(filter.Extension))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var items []models.Upload
offset := (filter.Page - 1) * filter.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
// GetActiveUploadByID loads a non-deleted upload by ID.
func GetActiveUploadByID(ctx context.Context, id uint64) (models.Upload, error) {
var upload models.Upload
if err := db.DB(ctx).Where("id = ? AND status != ?", id, models.UploadStatusDeleted).First(&upload).Error; err != nil {
return models.Upload{}, err
}
return upload, nil
}
// SoftDeleteUpload marks an upload as deleted.
func SoftDeleteUpload(ctx context.Context, upload *models.Upload) error {
return SoftDeleteUploadTx(db.DB(ctx), upload)
}
// SoftDeleteUploadTx marks an upload as deleted within an existing transaction.
func SoftDeleteUploadTx(tx *gorm.DB, upload *models.Upload) error {
return tx.Model(upload).Update("status", models.UploadStatusDeleted).Error
}
// UpdateUpload applies partial field updates to an upload record.
func UpdateUpload(ctx context.Context, upload *models.Upload, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
return db.DB(ctx).Model(upload).Updates(updates).Error
}
// ListDistinctUploadTypes returns all distinct non-empty upload business types.
func ListDistinctUploadTypes(ctx context.Context) ([]string, error) {
var types []string
if err := db.DB(ctx).Model(&models.Upload{}).
Where("type IS NOT NULL AND type != ''").
Distinct().
Pluck("type", &types).Error; err != nil {
return nil, err
}
return types, nil
}
// FindReusableUploadByHash finds an existing upload with the same hash and size.
func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (models.Upload, error) {
var existing models.Upload
err := db.DB(ctx).
Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, models.UploadStatusPending, models.UploadStatusUsed).
First(&existing).Error
return existing, err
}
// CreateUpload persists a new upload record.
func CreateUpload(ctx context.Context, upload *models.Upload) error {
return CreateUploadTx(db.DB(ctx), upload)
}
// CreateUploadTx persists a new upload record within an existing transaction.
func CreateUploadTx(tx *gorm.DB, upload *models.Upload) error {
return tx.Create(upload).Error
}
// ListUploadsByIDs returns active uploads matching the given IDs.
func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]models.Upload, error) {
var uploads []models.Upload
if err := db.DB(ctx).
Where("id IN ? AND status IN (?, ?)", ids, models.UploadStatusPending, models.UploadStatusUsed).
Find(&uploads).Error; err != nil {
return nil, err
}
return uploads, nil
}
// UploadQuery returns a scoped GORM query for uploads.
func UploadQuery(ctx context.Context) *gorm.DB {
return db.DB(ctx).Model(&models.Upload{})
}
// ListUploadStats returns all upload statistics rows.
func ListUploadStats(ctx context.Context) ([]models.UploadStat, error) {
var stats []models.UploadStat
if err := db.DB(ctx).Find(&stats).Error; err != nil {
return nil, err
}
return stats, nil
}
@@ -17,4 +17,9 @@ const (
FileStatsTrendDays = 7
MaxS3KeyLength = 1024
AccessCacheTTL = 5 // seconds; multiplied by time.Second at use site
UploadStatDimensionTotal = "total"
UploadStatDimensionType = "type"
UploadStatDimensionCategory = "category"
UploadStatDimensionTrend = "trend"
)
+2
View File
@@ -38,4 +38,6 @@ const (
ErrInvalidImageCacheWarmupQuality = "图片质量仅支持 low、medium、high"
ErrParseImageCacheWarmupPayload = "解析图片缓存预热参数失败: %w"
ErrQueryImagesForCacheWarmup = "查询待预热图片失败: %w"
ErrQueryTypeListFailed = "查询文件类型列表失败"
ErrUpdateFileFailed = "更新文件失败"
)
+18 -18
View File
@@ -7,32 +7,32 @@ import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ApplyUploadStatsAdd increments incremental stats for a newly active upload record.
func ApplyUploadStatsAdd(ctx context.Context, upload *model.Upload) error {
func ApplyUploadStatsAdd(ctx context.Context, upload *models.Upload) error {
return applyUploadStatsDelta(ctx, upload, 1)
}
// ApplyUploadStatsRemove decrements incremental stats for a removed active upload record.
func ApplyUploadStatsRemove(ctx context.Context, upload *model.Upload) error {
func ApplyUploadStatsRemove(ctx context.Context, upload *models.Upload) error {
return applyUploadStatsDelta(ctx, upload, -1)
}
// RebuildUploadStats rebuilds all incremental stats from current upload records.
func RebuildUploadStats(ctx context.Context) error {
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&model.UploadStat{}).Error; err != nil {
if err := tx.Where("1 = 1").Delete(&models.UploadStat{}).Error; err != nil {
return err
}
var uploads []model.Upload
if err := tx.Where("status != ?", model.UploadStatusDeleted).Find(&uploads).Error; err != nil {
var uploads []models.Upload
if err := tx.Where("status != ?", models.UploadStatusDeleted).Find(&uploads).Error; err != nil {
return err
}
@@ -45,7 +45,7 @@ func RebuildUploadStats(ctx context.Context) error {
})
}
func applyUploadStatsDelta(ctx context.Context, upload *model.Upload, sign int64) error {
func applyUploadStatsDelta(ctx context.Context, upload *models.Upload, sign int64) error {
if upload == nil || !isActiveUploadStatus(upload.Status) {
return nil
}
@@ -55,7 +55,7 @@ func applyUploadStatsDelta(ctx context.Context, upload *model.Upload, sign int64
}
// ApplyUploadStatsDeltaTx applies incremental upload stats within an existing transaction.
func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *model.Upload, sign int64) error {
func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *models.Upload, sign int64) error {
if upload == nil || !isActiveUploadStatus(upload.Status) || sign == 0 {
return nil
}
@@ -71,10 +71,10 @@ func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *model.Upload, sign int64) erro
dimension string
key string
}{
{model.UploadStatDimensionTotal, ""},
{model.UploadStatDimensionType, typeKey},
{model.UploadStatDimensionCategory, GetFileCategory(upload.MimeType, upload.Extension)},
{model.UploadStatDimensionTrend, upload.CreatedAt.Format("2006-01-02")},
{models.UploadStatDimensionTotal, ""},
{models.UploadStatDimensionType, typeKey},
{models.UploadStatDimensionCategory, GetFileCategory(upload.MimeType, upload.Extension)},
{models.UploadStatDimensionTrend, upload.CreatedAt.Format("2006-01-02")},
}
for _, entry := range entries {
@@ -104,7 +104,7 @@ func upsertUploadStatDelta(tx *gorm.DB, dimension, key string, countDelta, sizeD
),
"updated_at": time.Now(),
}),
}).Create(&model.UploadStat{
}).Create(&models.UploadStat{
Dimension: dimension,
StatKey: key,
FileCount: countDelta,
@@ -113,19 +113,19 @@ func upsertUploadStatDelta(tx *gorm.DB, dimension, key string, countDelta, sizeD
}
// RecordUploadStatsAdd logs and applies upload stats increment.
func RecordUploadStatsAdd(ctx context.Context, upload *model.Upload) {
func RecordUploadStatsAdd(ctx context.Context, upload *models.Upload) {
if err := ApplyUploadStatsAdd(ctx, upload); err != nil {
logger.WarnF(ctx, "increment upload stats failed: %v", err)
}
}
// RecordUploadStatsRemove logs and applies upload stats decrement.
func RecordUploadStatsRemove(ctx context.Context, upload *model.Upload) {
func RecordUploadStatsRemove(ctx context.Context, upload *models.Upload) {
if err := ApplyUploadStatsRemove(ctx, upload); err != nil {
logger.WarnF(ctx, "decrement upload stats failed: %v", err)
}
}
func isActiveUploadStatus(status model.UploadStatus) bool {
return status == model.UploadStatusPending || status == model.UploadStatusUsed
func isActiveUploadStatus(status models.UploadStatus) bool {
return status == models.UploadStatusPending || status == models.UploadStatusUsed
}
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"gorm.io/gorm"
)
@@ -19,13 +19,13 @@ func TestApplyUploadStatsDeltaTxWithinTransaction(t *testing.T) {
defer cleanup()
ctx := context.Background()
upload := &model.Upload{
upload := &models.Upload{
ID: 42002,
FileSize: 256,
MimeType: "image/jpeg",
Extension: "jpg",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
@@ -49,13 +49,13 @@ func TestApplyUploadStatsAddAndRemove(t *testing.T) {
defer cleanup()
ctx := context.Background()
upload := &model.Upload{
upload := &models.Upload{
ID: 42001,
FileSize: 128,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := ApplyUploadStatsAdd(ctx, upload); err != nil {
@@ -89,8 +89,8 @@ type uploadStatsSnapshot struct {
}
func loadUploadStats(ctx context.Context) (uploadStatsSnapshot, error) {
var rows []model.UploadStat
if err := db.DB(ctx).Where("dimension = ?", model.UploadStatDimensionTotal).Find(&rows).Error; err != nil {
var rows []models.UploadStat
if err := db.DB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil {
return uploadStatsSnapshot{}, err
}
if len(rows) == 0 {
@@ -9,9 +9,10 @@ import (
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
)
// MigrationAccessState captures cached migration maintenance state.
@@ -70,9 +71,9 @@ func buildMigrationAccessState(ctx context.Context) MigrationAccessState {
}
state := MigrationAccessState{
ReadOnly: execution.Status != model.TaskExecutionStatusSucceeded,
ReadOnly: execution.Status != task.TaskExecutionStatusSucceeded,
}
if execution.Status == model.TaskExecutionStatusSucceeded {
if execution.Status == task.TaskExecutionStatusSucceeded {
return state
}
+5 -5
View File
@@ -10,17 +10,17 @@ import (
"fmt"
"strings"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
)
// StorageMigrationTask is the Asynq task name for storage migration.
const StorageMigrationTask = "storage:migrate"
// LatestMigrationExecution returns the most recent storage migration task execution.
func LatestMigrationExecution(ctx context.Context) (*model.TaskExecution, bool, error) {
return repository.GetLatestTaskExecutionByTaskType(ctx, StorageMigrationTask)
func LatestMigrationExecution(ctx context.Context) (*task.TaskExecution, bool, error) {
return task.GetLatestTaskExecutionByTaskType(ctx, StorageMigrationTask)
}
// ParseMigrationTargetConfig parses and validates a storage migration target payload.
+3 -3
View File
@@ -6,9 +6,9 @@ package storage
import (
"context"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
)
// ReadOnly checks if the storage system is in read-only maintenance mode.
@@ -22,7 +22,7 @@ func ReadOnly(ctx context.Context) bool {
}
// OpenStoredObject opens a stored upload object from the active storage backend.
func OpenStoredObject(ctx context.Context, upload *model.Upload) (*objectstore.Object, error) {
func OpenStoredObject(ctx context.Context, upload *models.Upload) (*objectstore.Object, error) {
_, backend, err := objectstore.Active(ctx)
if err != nil {
return nil, err
+14 -14
View File
@@ -10,17 +10,17 @@ import (
"fmt"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/repository/logstore"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/pkg/persistence/logstore"
"github.com/Rain-kl/Wavelet/pkg/task"
uploadcache "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
"gorm.io/gorm"
)
@@ -61,9 +61,9 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas
task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339))
for {
var unusedUploads []model.Upload
var unusedUploads []models.Upload
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo).
Where("id > ? AND status = ? AND created_at < ?", lastID, models.UploadStatusPending, oneHourAgo).
Order("id ASC").
Limit(batchSize).
Find(&unusedUploads).Error; err != nil {
@@ -81,9 +81,9 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas
totalProcessed++
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", u.ID, model.UploadStatusPending).
Update("status", model.UploadStatusDeleted).Error; err != nil {
if err := tx.Model(&models.Upload{}).
Where("id = ? AND status = ?", u.ID, models.UploadStatusPending).
Update("status", models.UploadStatusDeleted).Error; err != nil {
return err
}
@@ -112,10 +112,10 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas
task.AppendLog(ctx, "开始清理历史推送审计日志,只保留最近7天数据...")
cutoff := time.Now().AddDate(0, 0, -7)
var pushHistoryCount int64
if err := db.DB(ctx).Model(&model.PushHistory{}).Where("created_at < ?", cutoff).Count(&pushHistoryCount).Error; err != nil {
if err := db.DB(ctx).Table("w_push_histories").Where("created_at < ?", cutoff).Count(&pushHistoryCount).Error; err != nil {
task.AppendLog(ctx, "统计待清理的历史推送记录失败: %v", err)
} else if pushHistoryCount > 0 {
if err := db.DB(ctx).Where("created_at < ?", cutoff).Delete(&model.PushHistory{}).Error; err != nil {
if err := db.DB(ctx).Table("w_push_histories").Where("created_at < ?", cutoff).Delete(map[string]any{}).Error; err != nil {
task.AppendLog(ctx, "删除历史推送记录失败: %v", err)
} else {
task.AppendLog(ctx, "成功删除 %d 条历史推送记录 (截止时间: %s)", pushHistoryCount, cutoff.Format("2006-01-02 15:04:05"))
@@ -125,7 +125,7 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas
}
task.AppendLog(ctx, "开始清理任务执行日志:高频任务保留最近3天,低频任务保留最近30天...")
taskLogStats, err := repository.CleanupTaskExecutionLogs(ctx, time.Now())
taskLogStats, err := task.CleanupTaskExecutionLogs(ctx, time.Now())
if err != nil {
task.AppendLog(ctx, "清理任务执行日志失败: %v", err)
logger.ErrorF(ctx, "清理任务执行日志失败: %v", err)
+7 -7
View File
@@ -7,9 +7,9 @@ import (
"context"
"fmt"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats"
)
@@ -39,8 +39,8 @@ type RebuildUploadStatsHandler struct{}
func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
var activeCount int64
if err := db.DB(ctx).
Model(&model.Upload{}).
Where("status != ?", model.UploadStatusDeleted).
Model(&models.Upload{}).
Where("status != ?", models.UploadStatusDeleted).
Count(&activeCount).Error; err != nil {
task.AppendLog(ctx, "统计活跃上传记录失败: %v", err)
return nil, fmt.Errorf("count active uploads: %w", err)
@@ -53,9 +53,9 @@ func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*tas
return nil, fmt.Errorf("rebuild upload stats: %w", err)
}
var totalStat model.UploadStat
var totalStat models.UploadStat
if err := db.DB(ctx).
Where("dimension = ? AND stat_key = ?", model.UploadStatDimensionTotal, "").
Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, "").
First(&totalStat).Error; err != nil {
task.AppendLog(ctx, "读取总量统计失败: %v", err)
return nil, fmt.Errorf("load total upload stats: %w", err)
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
)
func TestRebuildUploadStatsHandler_Execute(t *testing.T) {
@@ -20,16 +20,16 @@ func TestRebuildUploadStatsHandler_Execute(t *testing.T) {
ctx := context.Background()
now := time.Now()
uploads := []model.Upload{
uploads := []models.Upload{
{
UserID: 1001, FileName: "a.jpg", FilePath: "uploads/a.jpg",
FileSize: 100, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash-a",
Type: "pixez_mirror", Status: model.UploadStatusUsed, CreatedAt: now,
Type: "pixez_mirror", Status: models.UploadStatusUsed, CreatedAt: now,
},
{
UserID: 1001, FileName: "b.png", FilePath: "uploads/b.png",
FileSize: 200, MimeType: "image/png", Extension: "png", Hash: "hash-b",
Type: "attachment", Status: model.UploadStatusUsed, CreatedAt: now,
Type: "attachment", Status: models.UploadStatusUsed, CreatedAt: now,
},
}
for i := range uploads {
@@ -39,8 +39,8 @@ func TestRebuildUploadStatsHandler_Execute(t *testing.T) {
}
// Corrupt stats to ensure rebuild recalculates from uploads.
if err := db.DB(ctx).Create(&model.UploadStat{
Dimension: model.UploadStatDimensionTotal,
if err := db.DB(ctx).Create(&models.UploadStat{
Dimension: models.UploadStatDimensionTotal,
StatKey: "",
FileCount: 0,
FileSize: 0,
@@ -57,9 +57,9 @@ func TestRebuildUploadStatsHandler_Execute(t *testing.T) {
t.Fatalf("Execute() returned empty result: %+v", result)
}
var totalStat model.UploadStat
var totalStat models.UploadStat
if err := db.DB(ctx).
Where("dimension = ? AND stat_key = ?", model.UploadStatDimensionTotal, "").
Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, "").
First(&totalStat).Error; err != nil {
t.Fatalf("load total stat failed: %v", err)
}
+15 -15
View File
@@ -15,13 +15,13 @@ import (
"sync/atomic"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/pkg/util"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats"
uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
"golang.org/x/sync/errgroup"
)
@@ -173,8 +173,8 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T
func countStorageObjects(ctx context.Context) (int64, error) {
var count int64
err := db.DB(ctx).Model(&model.Upload{}).
Where("status != ?", model.UploadStatusDeleted).
err := db.DB(ctx).Model(&models.Upload{}).
Where("status != ?", models.UploadStatusDeleted).
Distinct("file_path").
Count(&count).Error
return count, err
@@ -185,7 +185,7 @@ func hasUnresolvedMigrationTask(ctx context.Context) (bool, error) {
if err != nil || !ok {
return false, err
}
return execution.Status == model.TaskExecutionStatusPending || execution.Status == model.TaskExecutionStatusRunning, nil
return execution.Status == task.TaskExecutionStatusPending || execution.Status == task.TaskExecutionStatusRunning, nil
}
type migrationObject struct {
@@ -214,9 +214,9 @@ func migrateObjects(
task.AppendLog(ctx, "正在查询待迁移对象批次,当前已完成迁移: %d/%d", atomic.LoadInt64(&migrated), total)
var objects []migrationObject
query := db.DB(ctx).Model(&model.Upload{}).
query := db.DB(ctx).Model(&models.Upload{}).
Select("file_path, MAX(file_size) AS file_size, MAX(mime_type) AS mime_type, MAX(hash) AS hash").
Where("status != ?", model.UploadStatusDeleted)
Where("status != ?", models.UploadStatusDeleted)
if lastFilePath != "" {
query = query.Where("file_path > ?", lastFilePath)
}
@@ -311,8 +311,8 @@ func migrateSingleObject(
if targetResult.Key != obj.FilePath {
task.AppendLog(ctx, "[更新数据库] 正在更新文件路径: %s -> %s", obj.FilePath, targetResult.Key)
if err := db.DB(ctx).Model(&model.Upload{}).
Where("file_path = ? AND status != ?", obj.FilePath, model.UploadStatusDeleted).
if err := db.DB(ctx).Model(&models.Upload{}).
Where("file_path = ? AND status != ?", obj.FilePath, models.UploadStatusDeleted).
Update("file_path", targetResult.Key).Error; err != nil {
return fmt.Errorf("update migrated object %q: %w", obj.FilePath, err)
}
@@ -344,15 +344,15 @@ func markMissingMigrationObjectDeleted(
) error {
task.AppendLog(ctx, "警告: 源存储中物理文件不存在,标记为已删除并跳过: %s (错误: %v)", filePath, sourceErr)
var affectedUploads []model.Upload
var affectedUploads []models.Upload
if err := db.DB(ctx).
Where("file_path = ? AND status != ?", filePath, model.UploadStatusDeleted).
Where("file_path = ? AND status != ?", filePath, models.UploadStatusDeleted).
Find(&affectedUploads).Error; err != nil {
return fmt.Errorf("load missing object uploads %q: %w", filePath, err)
}
if err := db.DB(ctx).Model(&model.Upload{}).
if err := db.DB(ctx).Model(&models.Upload{}).
Where("file_path = ?", filePath).
Update("status", model.UploadStatusDeleted).Error; err != nil {
Update("status", models.UploadStatusDeleted).Error; err != nil {
return fmt.Errorf("update missing object %q: %w", filePath, err)
}
for i := range affectedUploads {
@@ -16,10 +16,10 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
@@ -59,7 +59,7 @@ func TestMigrationHandlerExecute(t *testing.T) {
t.Fatalf("Marshal(storageMigrationPayload) returned error: %v", err)
}
upload := model.Upload{
upload := models.Upload{
ID: 99101,
UserID: 1,
FileName: "test.txt",
@@ -69,7 +69,7 @@ func TestMigrationHandlerExecute(t *testing.T) {
Extension: "txt",
Hash: "hash",
Type: "attachment",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
}
if err := dbConn.Create(&upload).Error; err != nil {
t.Fatalf("Create(upload) returned error: %v", err)
@@ -101,7 +101,7 @@ func TestMigrationHandlerExecute(t *testing.T) {
t.Errorf("migrated content = %q, want %q", copied.String(), content)
}
var migrated model.Upload
var migrated models.Upload
if err := dbConn.First(&migrated, upload.ID).Error; err != nil {
t.Fatalf("First(upload) returned error: %v", err)
}
@@ -156,7 +156,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) {
}
// Case 1: Incorrect Hash (should fail validation)
uploadIncorrect := model.Upload{
uploadIncorrect := models.Upload{
ID: 99102,
UserID: 1,
FileName: "test-hash.txt",
@@ -166,7 +166,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) {
Extension: "txt",
Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // Invalid hash
Type: "attachment",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
}
if err := dbConn.Create(&uploadIncorrect).Error; err != nil {
t.Fatalf("Create(uploadIncorrect) returned error: %v", err)
@@ -202,7 +202,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) {
}
// Case 2: Correct Hash (should succeed)
if err := dbConn.Model(&model.Upload{}).Where("id = ?", uploadIncorrect.ID).Update("hash", correctHash).Error; err != nil {
if err := dbConn.Model(&models.Upload{}).Where("id = ?", uploadIncorrect.ID).Update("hash", correctHash).Error; err != nil {
t.Fatalf("Update hash to correct value returned error: %v", err)
}
@@ -215,7 +215,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) {
t.Fatal("Execute() result = nil, want non-nil")
}
var migrated model.Upload
var migrated models.Upload
if err := dbConn.First(&migrated, uploadIncorrect.ID).Error; err != nil {
t.Fatalf("First(upload) returned error: %v", err)
}
+5 -5
View File
@@ -12,10 +12,10 @@ import (
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
)
@@ -113,11 +113,11 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t
return nil, fmt.Errorf("image cache warmup canceled: %w", err)
}
var uploads []model.Upload
var uploads []models.Upload
if err := db.DB(ctx).
Where("id > ? AND status != ? AND (LOWER(mime_type) LIKE ? OR LOWER(extension) IN ?)",
lastID,
model.UploadStatusDeleted,
models.UploadStatusDeleted,
"image/%",
[]string{"jpg", "jpeg", "png", "webp", "gif"},
).
+30 -30
View File
@@ -17,15 +17,15 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/infra/diskcache"
"github.com/Rain-kl/Wavelet/internal/infra/objectstore"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/Rain-kl/Wavelet/pkg/persistence"
"github.com/Rain-kl/Wavelet/pkg/task"
"github.com/Rain-kl/Wavelet/pkg/testhelper"
msg "github.com/Rain-kl/Wavelet/plugins/domain/message_gateway"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/models"
"github.com/Rain-kl/Wavelet/plugins/domain/upload/shared"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache"
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -48,39 +48,39 @@ func TestSystemCleanupHandler_Execute(t *testing.T) {
objectstore.ResetCache()
ctx := context.Background()
err := db.DB(ctx).AutoMigrate(&model.PushHistory{})
err := db.DB(ctx).AutoMigrate(&msg.PushHistory{})
require.NoError(t, err)
// 准备测试数据:创建一些上传记录
now := time.Now()
twoHoursAgo := now.Add(-2 * time.Hour)
records := []*model.Upload{
records := []*models.Upload{
// 超过1小时且状态为 pending 的记录 —— 应被清理
{
UserID: 1001, FileName: "old_file_1.jpg", FilePath: "uploads/old_1.jpg",
FileSize: 1024, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash1",
Type: "attachment", Status: model.UploadStatusPending,
Type: "attachment", Status: models.UploadStatusPending,
CreatedAt: twoHoursAgo,
},
{
UserID: 1001, FileName: "old_file_2.png", FilePath: "uploads/old_2.png",
FileSize: 2048, MimeType: "image/png", Extension: "png", Hash: "hash2",
Type: "attachment", Status: model.UploadStatusPending,
Type: "attachment", Status: models.UploadStatusPending,
CreatedAt: twoHoursAgo,
},
// 状态为 used 的记录 —— 不应被清理
{
UserID: 1001, FileName: "used_file.jpg", FilePath: "uploads/used.jpg",
FileSize: 512, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash3",
Type: "attachment", Status: model.UploadStatusUsed,
Type: "attachment", Status: models.UploadStatusUsed,
CreatedAt: twoHoursAgo,
},
// 不到1小时的 pending 记录 —— 不应被清理
{
UserID: 1001, FileName: "recent_file.jpg", FilePath: "uploads/recent.jpg",
FileSize: 256, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash4",
Type: "attachment", Status: model.UploadStatusPending,
Type: "attachment", Status: models.UploadStatusPending,
CreatedAt: now.Add(-10 * time.Minute),
},
}
@@ -90,7 +90,7 @@ func TestSystemCleanupHandler_Execute(t *testing.T) {
}
// 准备推送历史测试数据:1个旧的(应删除),1个新的(应保留)
oldPush := &model.PushHistory{
oldPush := &msg.PushHistory{
EventKey: "admin_login",
Channel: "email",
Target: "admin@test.com",
@@ -100,7 +100,7 @@ func TestSystemCleanupHandler_Execute(t *testing.T) {
Status: "success",
CreatedAt: now.AddDate(0, 0, -10),
}
newPush := &model.PushHistory{
newPush := &msg.PushHistory{
EventKey: "admin_login",
Channel: "lark",
Target: "http://webhook.com",
@@ -115,16 +115,16 @@ func TestSystemCleanupHandler_Execute(t *testing.T) {
err = db.DB(ctx).Create(newPush).Error
require.NoError(t, err)
oldTaskLog := &model.TaskExecution{
oldTaskLog := &task.TaskExecution{
TaskID: "old_low_frequency_task_log",
TaskType: "low:frequency",
TaskName: "低频任务",
Status: model.TaskExecutionStatusSucceeded,
Status: task.TaskExecutionStatusSucceeded,
CreatedAt: now.AddDate(0, 0, -31),
UpdatedAt: now.AddDate(0, 0, -31),
TriggeredBy: "system",
}
err = repository.CreateTaskExecution(ctx, oldTaskLog)
err = task.CreateTaskExecution(ctx, oldTaskLog)
require.NoError(t, err)
// 执行 handler
@@ -138,29 +138,29 @@ func TestSystemCleanupHandler_Execute(t *testing.T) {
// 验证数据库状态:pending 且超过1小时的应被标记为 deleted
var pendingCount int64
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusPending).Count(&pendingCount)
db.DB(ctx).Model(&models.Upload{}).Where("status = ?", models.UploadStatusPending).Count(&pendingCount)
assert.Equal(t, int64(1), pendingCount, "应只剩1条 pending 记录(最近的文件)")
var deletedCount int64
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusDeleted).Count(&deletedCount)
db.DB(ctx).Model(&models.Upload{}).Where("status = ?", models.UploadStatusDeleted).Count(&deletedCount)
assert.Equal(t, int64(2), deletedCount, "应有2条被标记为 deleted")
var usedCount int64
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusUsed).Count(&usedCount)
db.DB(ctx).Model(&models.Upload{}).Where("status = ?", models.UploadStatusUsed).Count(&usedCount)
assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响")
// 验证推送历史数据状态:10天前的应被删除,今天的应保留
var pushCount int64
db.DB(ctx).Model(&model.PushHistory{}).Count(&pushCount)
db.DB(ctx).Model(&msg.PushHistory{}).Count(&pushCount)
assert.Equal(t, int64(1), pushCount, "应只剩1条推送历史记录")
var remainingPush model.PushHistory
var remainingPush msg.PushHistory
err = db.DB(ctx).First(&remainingPush).Error
require.NoError(t, err)
assert.Equal(t, "New Login", remainingPush.Title)
var taskLogCount int64
err = db.DB(ctx).Model(&model.TaskExecution{}).Where("task_id = ?", "old_low_frequency_task_log").Count(&taskLogCount).Error
err = db.DB(ctx).Model(&task.TaskExecution{}).Where("task_id = ?", "old_low_frequency_task_log").Count(&taskLogCount).Error
require.NoError(t, err)
assert.Equal(t, int64(0), taskLogCount, "过期低频任务日志应被清理")
}
@@ -180,7 +180,7 @@ func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) {
defer storageMock()
ctx := context.Background()
err := db.DB(ctx).AutoMigrate(&model.PushHistory{})
err := db.DB(ctx).AutoMigrate(&msg.PushHistory{})
require.NoError(t, err)
// 没有任何上传记录
@@ -279,7 +279,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) {
writeTaskTestPNG(t, firstPath, color.RGBA{R: 255, A: 255})
writeTaskTestPNG(t, secondPath, color.RGBA{G: 255, A: 255})
records := []model.Upload{
records := []models.Upload{
{
ID: 4101,
UserID: 1001,
@@ -287,7 +287,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) {
FilePath: firstPath,
MimeType: "image/png",
Extension: "png",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 4102,
@@ -296,7 +296,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) {
FilePath: secondPath,
MimeType: "application/octet-stream",
Extension: "jpg",
Status: model.UploadStatusPending,
Status: models.UploadStatusPending,
},
{
ID: 4103,
@@ -305,7 +305,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) {
FilePath: filepath.Join(testDir, "notes.txt"),
MimeType: "text/plain",
Extension: "txt",
Status: model.UploadStatusUsed,
Status: models.UploadStatusUsed,
},
{
ID: 4104,
@@ -314,7 +314,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) {
FilePath: firstPath,
MimeType: "image/png",
Extension: "png",
Status: model.UploadStatusDeleted,
Status: models.UploadStatusDeleted,
},
}
for i := range records {
+3 -2
View File
@@ -41,9 +41,10 @@ func IsDocumentExtension(ext string) bool {
// NormalizeImageQuality normalizes the requested image quality query parameter.
func NormalizeImageQuality(quality string) string {
switch strings.ToLower(quality) {
q := strings.TrimSpace(strings.ToLower(quality))
switch q {
case shared.ImageQualityLow, shared.ImageQualityMedium, shared.ImageQualityHigh:
return strings.ToLower(quality)
return q
default:
return shared.ImageQualityOrigin
}