统一文件上传方式为直接上传,更新环境变量示例
This commit is contained in:
@@ -27,6 +27,9 @@ type UserService interface {
|
||||
ResetPassword(ctx context.Context, email, newPassword string) error
|
||||
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
||||
|
||||
// 头像上传
|
||||
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
|
||||
|
||||
// URL验证
|
||||
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
||||
|
||||
@@ -55,8 +58,7 @@ type ProfileService interface {
|
||||
// TextureService 材质服务接口
|
||||
type TextureService interface {
|
||||
// 材质CRUD
|
||||
Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error)
|
||||
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) // 直接上传材质文件
|
||||
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error)
|
||||
GetByID(ctx context.Context, id int64) (*model.Texture, error)
|
||||
GetByHash(ctx context.Context, hash string) (*model.Texture, error)
|
||||
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
||||
@@ -98,12 +100,6 @@ type CaptchaService interface {
|
||||
Verify(ctx context.Context, dx int, captchaID string) (bool, error)
|
||||
}
|
||||
|
||||
// UploadService 上传服务接口
|
||||
type UploadService interface {
|
||||
GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error)
|
||||
GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error)
|
||||
}
|
||||
|
||||
// YggdrasilService Yggdrasil服务接口
|
||||
type YggdrasilService interface {
|
||||
// 用户认证
|
||||
@@ -211,7 +207,6 @@ type Services struct {
|
||||
Token TokenService
|
||||
Verification VerificationService
|
||||
Captcha CaptchaService
|
||||
Upload UploadService
|
||||
Yggdrasil YggdrasilService
|
||||
Security SecurityService
|
||||
}
|
||||
|
||||
@@ -48,57 +48,6 @@ func NewTextureService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *textureService) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
|
||||
// 验证用户存在
|
||||
user, err := s.userRepo.FindByID(ctx, uploaderID)
|
||||
if err != nil || user == nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// 检查是否有任何用户上传过相同Hash的皮肤(复用URL,不重复保存文件)
|
||||
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果已存在相同Hash的皮肤,复用已存在的URL
|
||||
finalURL := url
|
||||
if existingTexture != nil {
|
||||
finalURL = existingTexture.URL
|
||||
}
|
||||
|
||||
// 转换材质类型
|
||||
textureTypeEnum, err := parseTextureTypeInternal(textureType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建材质记录(即使Hash相同,也创建新的数据库记录)
|
||||
texture := &model.Texture{
|
||||
UploaderID: uploaderID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Type: textureTypeEnum,
|
||||
URL: finalURL, // 复用已存在的URL或使用新URL
|
||||
Hash: hash,
|
||||
Size: size,
|
||||
IsPublic: isPublic,
|
||||
IsSlim: isSlim,
|
||||
Status: 1,
|
||||
DownloadCount: 0,
|
||||
FavoriteCount: 0,
|
||||
}
|
||||
|
||||
if err := s.textureRepo.Create(ctx, texture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清除用户的 texture 列表缓存(所有分页)
|
||||
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
||||
|
||||
return texture, nil
|
||||
}
|
||||
|
||||
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
|
||||
// 尝试从缓存获取
|
||||
cacheKey := s.cacheKeys.Texture(id)
|
||||
|
||||
@@ -478,130 +478,6 @@ func boolPtr(b bool) *bool {
|
||||
// 使用 Mock 的集成测试
|
||||
// ============================================================================
|
||||
|
||||
// TestTextureServiceImpl_Create 测试创建Texture
|
||||
func TestTextureServiceImpl_Create(t *testing.T) {
|
||||
textureRepo := NewMockTextureRepository()
|
||||
userRepo := NewMockUserRepository()
|
||||
logger := zap.NewNop()
|
||||
|
||||
// 预置用户
|
||||
testUser := &model.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Status: 1,
|
||||
}
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
// 在测试中传入nil作为storageClient,因为测试不涉及文件上传
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uploaderID int64
|
||||
textureName string
|
||||
textureType string
|
||||
hash string
|
||||
wantErr bool
|
||||
errContains string
|
||||
setupMocks func()
|
||||
}{
|
||||
{
|
||||
name: "正常创建SKIN材质",
|
||||
uploaderID: 1,
|
||||
textureName: "TestSkin",
|
||||
textureType: "SKIN",
|
||||
hash: "unique-hash-1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "正常创建CAPE材质",
|
||||
uploaderID: 1,
|
||||
textureName: "TestCape",
|
||||
textureType: "CAPE",
|
||||
hash: "unique-hash-2",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "用户不存在",
|
||||
uploaderID: 999,
|
||||
textureName: "TestTexture",
|
||||
textureType: "SKIN",
|
||||
hash: "unique-hash-3",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "材质Hash已存在,应该成功创建并复用URL",
|
||||
uploaderID: 1,
|
||||
textureName: "DuplicateTexture",
|
||||
textureType: "SKIN",
|
||||
hash: "existing-hash",
|
||||
wantErr: false, // 业务逻辑允许相同Hash存在,只是复用URL
|
||||
errContains: "",
|
||||
setupMocks: func() {
|
||||
_ = textureRepo.Create(context.Background(), &model.Texture{
|
||||
ID: 100,
|
||||
UploaderID: 1,
|
||||
Name: "ExistingTexture",
|
||||
Hash: "existing-hash",
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效的材质类型",
|
||||
uploaderID: 1,
|
||||
textureName: "InvalidTypeTexture",
|
||||
textureType: "INVALID",
|
||||
hash: "unique-hash-4",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupMocks != nil {
|
||||
tt.setupMocks()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
texture, err := textureService.Create(
|
||||
ctx,
|
||||
tt.uploaderID,
|
||||
tt.textureName,
|
||||
"Test description",
|
||||
tt.textureType,
|
||||
"http://example.com/texture.png",
|
||||
tt.hash,
|
||||
512,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("期望返回错误,但实际没有错误")
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
||||
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("不期望返回错误: %v", err)
|
||||
return
|
||||
}
|
||||
if texture == nil {
|
||||
t.Error("返回的Texture不应为nil")
|
||||
}
|
||||
if texture.Name != tt.textureName {
|
||||
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextureServiceImpl_GetByID 测试获取Texture
|
||||
func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||
textureRepo := NewMockTextureRepository()
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/storage"
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileType 文件类型枚举
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeAvatar FileType = "avatar"
|
||||
FileTypeTexture FileType = "texture"
|
||||
)
|
||||
|
||||
// UploadConfig 上传配置
|
||||
type UploadConfig struct {
|
||||
AllowedExts map[string]bool // 允许的文件扩展名
|
||||
MinSize int64 // 最小文件大小(字节)
|
||||
MaxSize int64 // 最大文件大小(字节)
|
||||
Expires time.Duration // URL过期时间
|
||||
}
|
||||
|
||||
// uploadService UploadService的实现
|
||||
type uploadService struct {
|
||||
storage *storage.StorageClient
|
||||
}
|
||||
|
||||
// NewUploadService 创建UploadService实例
|
||||
func NewUploadService(storageClient *storage.StorageClient) UploadService {
|
||||
return &uploadService{
|
||||
storage: storageClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAvatarUploadURL 生成头像上传URL
|
||||
func (s *uploadService) GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) {
|
||||
// 1. 验证文件名
|
||||
if err := ValidateFileName(fileName, FileTypeAvatar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 获取上传配置
|
||||
uploadConfig := GetUploadConfig(FileTypeAvatar)
|
||||
|
||||
// 3. 获取存储桶名称
|
||||
bucketName, err := s.storage.GetBucket("avatars")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 生成对象名称(路径)
|
||||
// 格式: user_{userId}/timestamp_{originalFileName}
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
objectName := fmt.Sprintf("user_%d/%s_%s", userID, timestamp, fileName)
|
||||
|
||||
// 5. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
|
||||
result, err := s.storage.GeneratePresignedPostURL(
|
||||
ctx,
|
||||
bucketName,
|
||||
objectName,
|
||||
uploadConfig.MinSize,
|
||||
uploadConfig.MaxSize,
|
||||
uploadConfig.Expires,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成上传URL失败: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GenerateTextureUploadURL 生成材质上传URL
|
||||
func (s *uploadService) GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) {
|
||||
// 1. 验证文件名
|
||||
if err := ValidateFileName(fileName, FileTypeTexture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 验证材质类型
|
||||
if textureType != "SKIN" && textureType != "CAPE" {
|
||||
return nil, fmt.Errorf("无效的材质类型: %s", textureType)
|
||||
}
|
||||
|
||||
// 3. 获取上传配置
|
||||
uploadConfig := GetUploadConfig(FileTypeTexture)
|
||||
|
||||
// 4. 获取存储桶名称
|
||||
bucketName, err := s.storage.GetBucket("textures")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
|
||||
// 5. 生成对象名称(路径)
|
||||
// 格式: user_{userId}/{textureType}/timestamp_{originalFileName}
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
textureTypeFolder := strings.ToLower(textureType)
|
||||
objectName := fmt.Sprintf("user_%d/%s/%s_%s", userID, textureTypeFolder, timestamp, fileName)
|
||||
|
||||
// 6. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
|
||||
result, err := s.storage.GeneratePresignedPostURL(
|
||||
ctx,
|
||||
bucketName,
|
||||
objectName,
|
||||
uploadConfig.MinSize,
|
||||
uploadConfig.MaxSize,
|
||||
uploadConfig.Expires,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成上传URL失败: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetUploadConfig 根据文件类型获取上传配置
|
||||
func GetUploadConfig(fileType FileType) *UploadConfig {
|
||||
switch fileType {
|
||||
case FileTypeAvatar:
|
||||
return &UploadConfig{
|
||||
AllowedExts: map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
},
|
||||
MinSize: 512, // 512B
|
||||
MaxSize: 5 * 1024 * 1024, // 5MB
|
||||
Expires: 15 * time.Minute,
|
||||
}
|
||||
case FileTypeTexture:
|
||||
return &UploadConfig{
|
||||
AllowedExts: map[string]bool{
|
||||
".png": true,
|
||||
},
|
||||
MinSize: 512, // 512B
|
||||
MaxSize: 10 * 1024 * 1024, // 10MB
|
||||
Expires: 15 * time.Minute,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateFileName 验证文件名
|
||||
func ValidateFileName(fileName string, fileType FileType) error {
|
||||
if fileName == "" {
|
||||
return fmt.Errorf("文件名不能为空")
|
||||
}
|
||||
|
||||
uploadConfig := GetUploadConfig(fileType)
|
||||
if uploadConfig == nil {
|
||||
return fmt.Errorf("不支持的文件类型")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if !uploadConfig.AllowedExts[ext] {
|
||||
return fmt.Errorf("不支持的文件格式: %s", ext)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"carrotskin/pkg/storage"
|
||||
)
|
||||
|
||||
// TestUploadService_FileTypes 测试文件类型常量
|
||||
func TestUploadService_FileTypes(t *testing.T) {
|
||||
if FileTypeAvatar == "" {
|
||||
t.Error("FileTypeAvatar should not be empty")
|
||||
}
|
||||
|
||||
if FileTypeTexture == "" {
|
||||
t.Error("FileTypeTexture should not be empty")
|
||||
}
|
||||
|
||||
if FileTypeAvatar == FileTypeTexture {
|
||||
t.Error("FileTypeAvatar and FileTypeTexture should be different")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUploadConfig 测试获取上传配置
|
||||
func TestGetUploadConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileType FileType
|
||||
wantConfig bool
|
||||
}{
|
||||
{
|
||||
name: "头像类型返回配置",
|
||||
fileType: FileTypeAvatar,
|
||||
wantConfig: true,
|
||||
},
|
||||
{
|
||||
name: "材质类型返回配置",
|
||||
fileType: FileTypeTexture,
|
||||
wantConfig: true,
|
||||
},
|
||||
{
|
||||
name: "无效类型返回nil",
|
||||
fileType: FileType("invalid"),
|
||||
wantConfig: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := GetUploadConfig(tt.fileType)
|
||||
hasConfig := config != nil
|
||||
if hasConfig != tt.wantConfig {
|
||||
t.Errorf("GetUploadConfig() = %v, want %v", hasConfig, tt.wantConfig)
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
// 验证配置字段
|
||||
if config.MinSize <= 0 {
|
||||
t.Error("MinSize should be greater than 0")
|
||||
}
|
||||
if config.MaxSize <= 0 {
|
||||
t.Error("MaxSize should be greater than 0")
|
||||
}
|
||||
if config.MaxSize < config.MinSize {
|
||||
t.Error("MaxSize should be greater than or equal to MinSize")
|
||||
}
|
||||
if config.Expires <= 0 {
|
||||
t.Error("Expires should be greater than 0")
|
||||
}
|
||||
if len(config.AllowedExts) == 0 {
|
||||
t.Error("AllowedExts should not be empty")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUploadConfig_AvatarConfig 测试头像配置详情
|
||||
func TestGetUploadConfig_AvatarConfig(t *testing.T) {
|
||||
config := GetUploadConfig(FileTypeAvatar)
|
||||
if config == nil {
|
||||
t.Fatal("Avatar config should not be nil")
|
||||
}
|
||||
|
||||
// 验证允许的扩展名
|
||||
expectedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
for _, ext := range expectedExts {
|
||||
if !config.AllowedExts[ext] {
|
||||
t.Errorf("Avatar config should allow %s extension", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证文件大小限制
|
||||
if config.MinSize != 512 {
|
||||
t.Errorf("Avatar MinSize = %d, want 512", config.MinSize)
|
||||
}
|
||||
|
||||
if config.MaxSize != 5*1024*1024 {
|
||||
t.Errorf("Avatar MaxSize = %d, want 5MB", config.MaxSize)
|
||||
}
|
||||
|
||||
// 验证过期时间
|
||||
if config.Expires != 15*time.Minute {
|
||||
t.Errorf("Avatar Expires = %v, want 15 minutes", config.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUploadConfig_TextureConfig 测试材质配置详情
|
||||
func TestGetUploadConfig_TextureConfig(t *testing.T) {
|
||||
config := GetUploadConfig(FileTypeTexture)
|
||||
if config == nil {
|
||||
t.Fatal("Texture config should not be nil")
|
||||
}
|
||||
|
||||
// 验证允许的扩展名(材质只允许PNG)
|
||||
if !config.AllowedExts[".png"] {
|
||||
t.Error("Texture config should allow .png extension")
|
||||
}
|
||||
|
||||
// 验证文件大小限制
|
||||
if config.MinSize != 512 {
|
||||
t.Errorf("Texture MinSize = %d, want 512", config.MinSize)
|
||||
}
|
||||
|
||||
if config.MaxSize != 10*1024*1024 {
|
||||
t.Errorf("Texture MaxSize = %d, want 10MB", config.MaxSize)
|
||||
}
|
||||
|
||||
// 验证过期时间
|
||||
if config.Expires != 15*time.Minute {
|
||||
t.Errorf("Texture Expires = %v, want 15 minutes", config.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileName 测试文件名验证
|
||||
func TestValidateFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
fileType FileType
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "有效的头像文件名",
|
||||
fileName: "avatar.png",
|
||||
fileType: FileTypeAvatar,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "有效的材质文件名",
|
||||
fileName: "texture.png",
|
||||
fileType: FileTypeTexture,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "文件名为空",
|
||||
fileName: "",
|
||||
fileType: FileTypeAvatar,
|
||||
wantErr: true,
|
||||
errContains: "文件名不能为空",
|
||||
},
|
||||
{
|
||||
name: "不支持的文件扩展名",
|
||||
fileName: "file.txt",
|
||||
fileType: FileTypeAvatar,
|
||||
wantErr: true,
|
||||
errContains: "不支持的文件格式",
|
||||
},
|
||||
{
|
||||
name: "无效的文件类型",
|
||||
fileName: "file.png",
|
||||
fileType: FileType("invalid"),
|
||||
wantErr: true,
|
||||
errContains: "不支持的文件类型",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateFileName(tt.fileName, tt.fileType)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateFileName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr && tt.errContains != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("ValidateFileName() error = %v, should contain %s", err, tt.errContains)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileName_Extensions 测试各种扩展名
|
||||
func TestValidateFileName_Extensions(t *testing.T) {
|
||||
avatarExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
for _, ext := range avatarExts {
|
||||
fileName := "test" + ext
|
||||
err := ValidateFileName(fileName, FileTypeAvatar)
|
||||
if err != nil {
|
||||
t.Errorf("Avatar file with %s extension should be valid, got error: %v", ext, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 材质只支持PNG
|
||||
textureExts := []string{".png"}
|
||||
for _, ext := range textureExts {
|
||||
fileName := "test" + ext
|
||||
err := ValidateFileName(fileName, FileTypeTexture)
|
||||
if err != nil {
|
||||
t.Errorf("Texture file with %s extension should be valid, got error: %v", ext, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试不支持的扩展名
|
||||
invalidExts := []string{".txt", ".pdf", ".doc"}
|
||||
for _, ext := range invalidExts {
|
||||
fileName := "test" + ext
|
||||
err := ValidateFileName(fileName, FileTypeAvatar)
|
||||
if err == nil {
|
||||
t.Errorf("Avatar file with %s extension should be invalid", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileName_CaseInsensitive 测试扩展名大小写不敏感
|
||||
func TestValidateFileName_CaseInsensitive(t *testing.T) {
|
||||
testCases := []struct {
|
||||
fileName string
|
||||
fileType FileType
|
||||
wantErr bool
|
||||
}{
|
||||
{"test.PNG", FileTypeAvatar, false},
|
||||
{"test.JPG", FileTypeAvatar, false},
|
||||
{"test.JPEG", FileTypeAvatar, false},
|
||||
{"test.GIF", FileTypeAvatar, false},
|
||||
{"test.WEBP", FileTypeAvatar, false},
|
||||
{"test.PnG", FileTypeTexture, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.fileName, func(t *testing.T) {
|
||||
err := ValidateFileName(tc.fileName, tc.fileType)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ValidateFileName(%s, %s) error = %v, wantErr %v", tc.fileName, tc.fileType, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadConfig_Structure 测试UploadConfig结构
|
||||
func TestUploadConfig_Structure(t *testing.T) {
|
||||
config := &UploadConfig{
|
||||
AllowedExts: map[string]bool{
|
||||
".png": true,
|
||||
},
|
||||
MinSize: 512,
|
||||
MaxSize: 5 * 1024 * 1024,
|
||||
Expires: 15 * time.Minute,
|
||||
}
|
||||
|
||||
if config.AllowedExts == nil {
|
||||
t.Error("AllowedExts should not be nil")
|
||||
}
|
||||
|
||||
if config.MinSize <= 0 {
|
||||
t.Error("MinSize should be greater than 0")
|
||||
}
|
||||
|
||||
if config.MaxSize <= config.MinSize {
|
||||
t.Error("MaxSize should be greater than MinSize")
|
||||
}
|
||||
|
||||
if config.Expires <= 0 {
|
||||
t.Error("Expires should be greater than 0")
|
||||
}
|
||||
}
|
||||
|
||||
// mockStorageClient 用于单元测试的简单存储客户端假实现
|
||||
// 注意:这里只声明与 upload_service 使用到的方法,避免依赖真实 MinIO 客户端
|
||||
type mockStorageClient struct {
|
||||
getBucketFn func(name string) (string, error)
|
||||
generatePresignedPostURLFn func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error)
|
||||
}
|
||||
|
||||
func (m *mockStorageClient) GetBucket(name string) (string, error) {
|
||||
if m.getBucketFn != nil {
|
||||
return m.getBucketFn(name)
|
||||
}
|
||||
return "", errors.New("GetBucket not implemented")
|
||||
}
|
||||
|
||||
func (m *mockStorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
||||
if m.generatePresignedPostURLFn != nil {
|
||||
return m.generatePresignedPostURLFn(ctx, bucketName, objectName, minSize, maxSize, expires)
|
||||
}
|
||||
return nil, errors.New("GeneratePresignedPostURL not implemented")
|
||||
}
|
||||
|
||||
// TestGenerateAvatarUploadURL_Success 测试头像上传URL生成成功
|
||||
func TestGenerateAvatarUploadURL_Success(t *testing.T) {
|
||||
// 由于 mockStorageClient 类型不匹配,跳过该测试
|
||||
t.Skip("This test requires refactoring to work with the new service architecture")
|
||||
|
||||
_ = &mockStorageClient{
|
||||
getBucketFn: func(name string) (string, error) {
|
||||
if name != "avatars" {
|
||||
t.Fatalf("unexpected bucket name: %s", name)
|
||||
}
|
||||
return "avatars-bucket", nil
|
||||
},
|
||||
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
||||
if bucketName != "avatars-bucket" {
|
||||
t.Fatalf("unexpected bucketName: %s", bucketName)
|
||||
}
|
||||
if !strings.Contains(objectName, "user_") {
|
||||
t.Fatalf("objectName should contain user_ prefix, got: %s", objectName)
|
||||
}
|
||||
if !strings.Contains(objectName, "avatar.png") {
|
||||
t.Fatalf("objectName should contain original file name, got: %s", objectName)
|
||||
}
|
||||
// 检查大小与过期时间传递
|
||||
if minSize != 512 {
|
||||
t.Fatalf("minSize = %d, want 512", minSize)
|
||||
}
|
||||
if maxSize != 5*1024*1024 {
|
||||
t.Fatalf("maxSize = %d, want 5MB", maxSize)
|
||||
}
|
||||
if expires != 15*time.Minute {
|
||||
t.Fatalf("expires = %v, want 15m", expires)
|
||||
}
|
||||
return &storage.PresignedPostPolicyResult{
|
||||
PostURL: "http://example.com/upload",
|
||||
FormData: map[string]string{"key": objectName},
|
||||
FileURL: "http://example.com/file/" + objectName,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestGenerateTextureUploadURL_Success 测试材质上传URL生成成功(SKIN/CAPE)
|
||||
func TestGenerateTextureUploadURL_Success(t *testing.T) {
|
||||
// 由于 mockStorageClient 类型不匹配,跳过该测试
|
||||
t.Skip("This test requires refactoring to work with the new service architecture")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
textureType string
|
||||
}{
|
||||
{"SKIN 材质", "SKIN"},
|
||||
{"CAPE 材质", "CAPE"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_ = &mockStorageClient{
|
||||
getBucketFn: func(name string) (string, error) {
|
||||
if name != "textures" {
|
||||
t.Fatalf("unexpected bucket name: %s", name)
|
||||
}
|
||||
return "textures-bucket", nil
|
||||
},
|
||||
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
||||
if bucketName != "textures-bucket" {
|
||||
t.Fatalf("unexpected bucketName: %s", bucketName)
|
||||
}
|
||||
if !strings.Contains(objectName, "texture.png") {
|
||||
t.Fatalf("objectName should contain original file name, got: %s", objectName)
|
||||
}
|
||||
if !strings.Contains(objectName, "/"+strings.ToLower(tt.textureType)+"/") {
|
||||
t.Fatalf("objectName should contain texture type folder, got: %s", objectName)
|
||||
}
|
||||
return &storage.PresignedPostPolicyResult{
|
||||
PostURL: "http://example.com/upload",
|
||||
FormData: map[string]string{"key": objectName},
|
||||
FileURL: "http://example.com/file/" + objectName,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -28,6 +33,7 @@ type userService struct {
|
||||
cache *database.CacheManager
|
||||
cacheKeys *database.CacheKeyBuilder
|
||||
cacheInv *database.CacheInvalidator
|
||||
storage *storage.StorageClient
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -38,6 +44,7 @@ func NewUserService(
|
||||
jwtService *auth.JWTService,
|
||||
redisClient *redis.Client,
|
||||
cacheManager *database.CacheManager,
|
||||
storageClient *storage.StorageClient,
|
||||
logger *zap.Logger,
|
||||
) UserService {
|
||||
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
||||
@@ -50,6 +57,7 @@ func NewUserService(
|
||||
cache: cacheManager,
|
||||
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||
cacheInv: database.NewCacheInvalidator(cacheManager),
|
||||
storage: storageClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
@@ -347,6 +355,67 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
|
||||
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
|
||||
}
|
||||
|
||||
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
|
||||
// 验证文件大小
|
||||
fileSize := len(fileData)
|
||||
const minSize = 512 // 512B
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
|
||||
return "", fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||
if !allowedExts[ext] {
|
||||
return "", fmt.Errorf("不支持的文件格式: %s,仅支持 jpg/jpeg/png/gif/webp", ext)
|
||||
}
|
||||
|
||||
// 检查存储服务
|
||||
if s.storage == nil {
|
||||
return "", errors.New("存储服务不可用")
|
||||
}
|
||||
|
||||
// 计算文件哈希
|
||||
hashBytes := sha256.Sum256(fileData)
|
||||
hash := hex.EncodeToString(hashBytes[:])
|
||||
|
||||
// 获取存储桶
|
||||
bucketName, err := s.storage.GetBucket("avatars")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
|
||||
// 生成对象路径: avatars/{hash[:2]}/{hash[2:4]}/{hash}{ext}
|
||||
objectName := fmt.Sprintf("%s/%s/%s%s", hash[:2], hash[2:4], hash, ext)
|
||||
|
||||
// 上传文件
|
||||
reader := bytes.NewReader(fileData)
|
||||
contentType := "image/" + strings.TrimPrefix(ext, ".")
|
||||
if ext == ".jpg" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
|
||||
return "", fmt.Errorf("上传文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建文件URL
|
||||
avatarURL := s.storage.BuildFileURL(bucketName, objectName)
|
||||
|
||||
// 更新用户头像
|
||||
if err := s.UpdateAvatar(ctx, userID, avatarURL); err != nil {
|
||||
return "", fmt.Errorf("更新用户头像失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("上传头像成功",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("hash", hash),
|
||||
zap.String("url", avatarURL),
|
||||
)
|
||||
|
||||
return avatarURL, nil
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxProfilesPerUser() int {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
||||
if err != nil || config == nil {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
||||
// 初始化Service
|
||||
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -259,7 +259,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -322,7 +322,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user2)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
||||
|
||||
// 未配置时走默认值
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user