统一文件上传方式为直接上传,更新环境变量示例

This commit is contained in:
2025-12-08 15:40:28 +08:00
parent aa75691c49
commit 63ca7eff0d
18 changed files with 197 additions and 1032 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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
},
}
})
}
}

View File

@@ -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 {

View File

@@ -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)
}