feat: Enhance texture upload functionality and API response format

- Introduced a new upload endpoint for direct texture file uploads, allowing users to upload textures with validation for size and format.
- Updated existing texture-related API responses to a standardized format, improving consistency across the application.
- Refactored texture service methods to handle file uploads and reuse existing texture URLs based on hash checks.
- Cleaned up Dockerfile and other files by removing unnecessary whitespace.
This commit is contained in:
lan
2025-12-04 20:07:30 +08:00
parent 0bcd9336c4
commit 8858fd1ede
16 changed files with 295 additions and 24 deletions

View File

@@ -57,6 +57,7 @@ type ProfileService interface {
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) // 直接上传材质文件
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)

View File

@@ -1,12 +1,18 @@
package service
import (
"bytes"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/storage"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
@@ -16,6 +22,7 @@ import (
type textureService struct {
textureRepo repository.TextureRepository
userRepo repository.UserRepository
storage *storage.StorageClient
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
@@ -26,12 +33,14 @@ type textureService struct {
func NewTextureService(
textureRepo repository.TextureRepository,
userRepo repository.UserRepository,
storageClient *storage.StorageClient,
cacheManager *database.CacheManager,
logger *zap.Logger,
) TextureService {
return &textureService{
textureRepo: textureRepo,
userRepo: userRepo,
storage: storageClient,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
@@ -46,13 +55,16 @@ func (s *textureService) Create(ctx context.Context, uploaderID int64, name, des
return nil, ErrUserNotFound
}
// 检查Hash是否已存在
// 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
// 如果已存在相同Hash的皮肤复用已存在的URL
finalURL := url
if existingTexture != nil {
return nil, errors.New("该材质已存在")
finalURL = existingTexture.URL
}
// 转换材质类型
@@ -61,13 +73,13 @@ func (s *textureService) Create(ctx context.Context, uploaderID int64, name, des
return nil, err
}
// 创建材质
// 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: url,
URL: finalURL, // 复用已存在的URL或使用新URL
Hash: hash,
Size: size,
IsPublic: isPublic,
@@ -304,6 +316,116 @@ func (s *textureService) CheckUploadLimit(ctx context.Context, uploaderID int64,
return nil
}
// UploadTexture 直接上传材质文件
func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil {
return nil, ErrUserNotFound
}
// 验证文件大小和扩展名
fileSize := len(fileData)
const minSize = 512 // 512B
const maxSize = 10 * 1024 * 1024 // 10MB
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
return nil, fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
}
// 验证文件扩展名只支持PNG
ext := strings.ToLower(filepath.Ext(fileName))
if ext != ".png" {
return nil, fmt.Errorf("不支持的文件格式: %s仅支持PNG格式", ext)
}
// 验证材质类型
if textureType != "SKIN" && textureType != "CAPE" {
return nil, errors.New("无效的材质类型")
}
// 计算文件SHA256哈希
hashBytes := sha256.Sum256(fileData)
hash := hex.EncodeToString(hashBytes[:])
// 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
var finalURL string
if existingTexture != nil {
// 如果已存在相同Hash的皮肤复用已存在的URL不重复上传
finalURL = existingTexture.URL
s.logger.Info("复用已存在的材质文件",
zap.String("hash", hash),
zap.String("url", finalURL),
)
} else {
// 如果不存在,上传到对象存储
if s.storage == nil {
return nil, errors.New("存储服务不可用")
}
// 获取存储桶名称
bucketName, err := s.storage.GetBucket("textures")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 生成对象名称(路径)
// 格式: hash/{hash[:2]}/{hash[2:4]}/{hash}.png
// 使用哈希值作为路径,避免重复存储相同文件
textureTypeFolder := strings.ToLower(textureType)
objectName := fmt.Sprintf("%s/%s/%s/%s/%s%s", textureTypeFolder, hash[:2], hash[2:4], hash, hash, ext)
// 上传文件
reader := bytes.NewReader(fileData)
contentType := "image/png"
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
return nil, fmt.Errorf("上传文件失败: %w", err)
}
// 构建文件URL
finalURL = s.storage.BuildFileURL(bucketName, objectName)
s.logger.Info("上传新的材质文件",
zap.String("hash", hash),
zap.String("url", finalURL),
)
}
// 转换材质类型
textureTypeEnum, err := parseTextureTypeInternal(textureType)
if err != nil {
return nil, err
}
// 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: finalURL,
Hash: hash,
Size: fileSize,
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
}
// parseTextureTypeInternal 解析材质类型
func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
switch textureType {

View File

@@ -572,7 +572,7 @@ func TestTextureServiceImpl_Create(t *testing.T) {
tt.textureType,
"http://example.com/texture.png",
tt.hash,
1024,
512,
true,
false,
)

View File

@@ -129,7 +129,7 @@ func GetUploadConfig(fileType FileType) *UploadConfig {
".gif": true,
".webp": true,
},
MinSize: 1024, // 1KB
MinSize: 512, // 512B
MaxSize: 5 * 1024 * 1024, // 5MB
Expires: 15 * time.Minute,
}
@@ -138,7 +138,7 @@ func GetUploadConfig(fileType FileType) *UploadConfig {
AllowedExts: map[string]bool{
".png": true,
},
MinSize: 1024, // 1KB
MinSize: 512, // 512B
MaxSize: 10 * 1024 * 1024, // 10MB
Expires: 15 * time.Minute,
}

View File

@@ -95,8 +95,8 @@ func TestGetUploadConfig_AvatarConfig(t *testing.T) {
}
// 验证文件大小限制
if config.MinSize != 1024 {
t.Errorf("Avatar MinSize = %d, want 1024", config.MinSize)
if config.MinSize != 512 {
t.Errorf("Avatar MinSize = %d, want 512", config.MinSize)
}
if config.MaxSize != 5*1024*1024 {
@@ -122,8 +122,8 @@ func TestGetUploadConfig_TextureConfig(t *testing.T) {
}
// 验证文件大小限制
if config.MinSize != 1024 {
t.Errorf("Texture MinSize = %d, want 1024", config.MinSize)
if config.MinSize != 512 {
t.Errorf("Texture MinSize = %d, want 512", config.MinSize)
}
if config.MaxSize != 10*1024*1024 {
@@ -259,7 +259,7 @@ func TestUploadConfig_Structure(t *testing.T) {
AllowedExts: map[string]bool{
".png": true,
},
MinSize: 1024,
MinSize: 512,
MaxSize: 5 * 1024 * 1024,
Expires: 15 * time.Minute,
}
@@ -325,8 +325,8 @@ func TestGenerateAvatarUploadURL_Success(t *testing.T) {
t.Fatalf("objectName should contain original file name, got: %s", objectName)
}
// 检查大小与过期时间传递
if minSize != 1024 {
t.Fatalf("minSize = %d, want 1024", minSize)
if minSize != 512 {
t.Fatalf("minSize = %d, want 512", minSize)
}
if maxSize != 5*1024*1024 {
t.Fatalf("maxSize = %d, want 5MB", maxSize)