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

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

@@ -1,18 +1,16 @@
# ==================== CarrotSkin Docker 环境配置示例 ====================
# 复制此文件为 .env 后修改配置值
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
# ==================== 服务配置 ====================
# 应用端口
# 应用对外端口
APP_PORT=8080
# 运行模式: debug, release, test
SERVER_MODE=release
# API 根路径 (用于反向代理,如 /api)
SERVER_BASE_PATH=
# 公开访问地址 (用于生成回调URL、邮件链接等)
PUBLIC_URL=http://localhost:8080
# ==================== 数据库配置 ====================
DB_PASSWORD=carrotskin123
# 数据库密码,生产环境务必修改
DATABASE_PASSWORD=carrotskin123
# ==================== Redis 配置 ====================
# 留空表示不设置密码
@@ -25,23 +23,26 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production
# ==================== 存储配置 (RustFS S3兼容) ====================
# 内部访问地址 (容器间通信)
RUSTFS_ENDPOINT=rustfs:9000
# 公开访问地址 (用于生成文件URL供外部浏览器访问)
# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage
RUSTFS_PUBLIC_URL=http://localhost:9000
RUSTFS_ACCESS_KEY=rustfsadmin
RUSTFS_SECRET_KEY=rustfsadmin123
RUSTFS_USE_SSL=false
# 存储桶配置
RUSTFS_BUCKET_TEXTURES=carrotskin
RUSTFS_BUCKET_AVATARS=carrotskin
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
# 公开访问地址 (用于生成文件URL供外部浏览器访问)
# 示例:
# 直接访问: http://localhost:9000
# 反向代理: https://example.com/storage
RUSTFS_PUBLIC_URL=http://localhost:9000
# ==================== 安全配置 ====================
# CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# ==================== 邮件配置 (可选) ====================
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# ==================== 邮件配置 ====================
EMAIL_ENABLED=false
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_NAME=CarrotSkin

View File

@@ -23,6 +23,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
DATABASE_MAX_IDLE_CONNS=10
DATABASE_MAX_OPEN_CONNS=100
DATABASE_CONN_MAX_LIFETIME=1h
DATABASE_CONN_MAX_IDLE_TIME=10m
# =============================================================================
# Redis配置
@@ -37,6 +38,7 @@ REDIS_POOL_SIZE=10
# RustFS对象存储配置 (S3兼容)
# =============================================================================
RUSTFS_ENDPOINT=127.0.0.1:9000
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=your_access_key
RUSTFS_SECRET_KEY=your_secret_key
RUSTFS_USE_SSL=false
@@ -55,26 +57,17 @@ JWT_EXPIRE_HOURS=168
LOG_LEVEL=info
LOG_FORMAT=json
LOG_OUTPUT=logs/app.log
LOG_MAX_SIZE=100
LOG_MAX_BACKUPS=3
LOG_MAX_AGE=28
LOG_COMPRESS=true
# =============================================================================
# 文件上传配置
# =============================================================================
UPLOAD_MAX_SIZE=10485760
UPLOAD_TEXTURE_MAX_SIZE=2097152
UPLOAD_AVATAR_MAX_SIZE=1048576
# =============================================================================
# 安全配置
# =============================================================================
MAX_LOGIN_ATTEMPTS=5
LOGIN_LOCK_DURATION=30m
# CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# =============================================================================
# 邮件配置(可选)
# 邮件配置
# 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465
# =============================================================================
EMAIL_ENABLED=false

View File

@@ -13,40 +13,43 @@ services:
- "${APP_PORT:-8080}:8080"
environment:
# 服务器配置
- SERVER_PORT=8080
- SERVER_PORT=:8080
- SERVER_MODE=${SERVER_MODE:-release}
- SERVER_BASE_PATH=${SERVER_BASE_PATH:-}
# 公开访问地址 (用于生成回调URL、邮件链接等)
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080}
# 数据库配置
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=carrotskin
- DB_PASSWORD=${DB_PASSWORD:-carrotskin123}
- DB_NAME=carrotskin
- DB_SSLMODE=disable
- DATABASE_DRIVER=postgres
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_USERNAME=carrotskin
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
- DATABASE_NAME=carrotskin
- DATABASE_SSL_MODE=disable
- DATABASE_TIMEZONE=Asia/Shanghai
# Redis 配置
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DB=0
- REDIS_DATABASE=0
# JWT 配置
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- JWT_EXPIRE_HOURS=24
- JWT_EXPIRE_HOURS=168
# 存储配置 (RustFS S3兼容)
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrotskin}
# 邮件配置 (可选)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
# 安全配置
- SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*}
- SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1}
# 邮件配置
- EMAIL_ENABLED=${EMAIL_ENABLED:-false}
- EMAIL_SMTP_HOST=${EMAIL_SMTP_HOST:-}
- EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-587}
- EMAIL_USERNAME=${EMAIL_USERNAME:-}
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
- EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-CarrotSkin}
depends_on:
postgres:
condition: service_healthy
@@ -68,7 +71,7 @@ services:
restart: unless-stopped
environment:
- POSTGRES_USER=carrotskin
- POSTGRES_PASSWORD=${DB_PASSWORD:-carrotskin123}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
- POSTGRES_DB=carrotskin
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
@@ -148,14 +151,19 @@ services:
echo '等待 RustFS 启动...';
sleep 5;
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
mc mb myrustfs/$${RUSTFS_BUCKET} --ignore-existing;
mc anonymous set download myrustfs/$${RUSTFS_BUCKET};
echo '存储桶 $${RUSTFS_BUCKET} 创建完成,已设置公开读取权限';
echo '创建材质存储桶...';
mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing;
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_TEXTURES};
echo '创建头像存储桶...';
mc mb myrustfs/$${RUSTFS_BUCKET_AVATARS} --ignore-existing;
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_AVATARS};
echo '存储桶创建完成: $${RUSTFS_BUCKET_TEXTURES}, $${RUSTFS_BUCKET_AVATARS}';
"
environment:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
- RUSTFS_BUCKET=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
networks:
- carrotskin-network
profiles:

View File

@@ -41,7 +41,6 @@ type Container struct {
TokenService service.TokenService
YggdrasilService service.YggdrasilService
VerificationService service.VerificationService
UploadService service.UploadService
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
@@ -87,7 +86,7 @@ func NewContainer(
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化Service注入缓存管理器
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger)
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger)
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
@@ -107,7 +106,6 @@ func NewContainer(
// 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient)
c.UploadService = service.NewUploadService(storageClient)
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化VerificationService需要email.Service
@@ -251,13 +249,6 @@ func WithVerificationService(svc service.VerificationService) Option {
}
}
// WithUploadService 设置上传服务
func WithUploadService(svc service.UploadService) Option {
return func(c *Container) {
c.UploadService = svc
}
}
// WithSecurityService 设置安全服务
func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) {

View File

@@ -95,8 +95,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW
userGroup.PUT("/profile", h.UpdateProfile)
// 头像相关
userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL)
userGroup.PUT("/avatar", h.UpdateAvatar)
userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL外部URL
// 更换邮箱
userGroup.POST("/change-email", h.ChangeEmail)
@@ -122,9 +122,7 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
textureAuth := textureGroup.Group("")
textureAuth.Use(middleware.AuthMiddleware(jwtService))
{
textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL保留兼容性
textureAuth.POST("", h.Create) // 创建材质记录配合预签名URL使用
textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.PUT("/:id", h.Update)
textureAuth.DELETE("/:id", h.Delete)
textureAuth.POST("/:id/favorite", h.ToggleFavorite)

View File

@@ -25,93 +25,6 @@ func NewTextureHandler(c *container.Container) *TextureHandler {
}
}
// GenerateUploadURL 生成材质上传URL
func (h *TextureHandler) GenerateUploadURL(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.GenerateTextureUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "请求参数错误", err)
return
}
if h.container.Storage == nil {
RespondServerError(c, "存储服务不可用", nil)
return
}
result, err := h.container.UploadService.GenerateTextureUploadURL(
c.Request.Context(),
userID,
req.FileName,
string(req.TextureType),
)
if err != nil {
h.logger.Error("生成材质上传URL失败",
zap.Int64("user_id", userID),
zap.String("file_name", req.FileName),
zap.String("texture_type", string(req.TextureType)),
zap.Error(err),
)
RespondBadRequest(c, err.Error(), nil)
return
}
RespondSuccess(c, &types.GenerateTextureUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
TextureURL: result.FileURL,
ExpiresIn: 900,
})
}
// Create 创建材质记录
func (h *TextureHandler) Create(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.CreateTextureRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "请求参数错误", err)
return
}
maxTextures := h.container.UserService.GetMaxTexturesPerUser()
if err := h.container.TextureService.CheckUploadLimit(c.Request.Context(), userID, maxTextures); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
texture, err := h.container.TextureService.Create(
c.Request.Context(),
userID,
req.Name,
req.Description,
string(req.Type),
req.URL,
req.Hash,
req.Size,
req.IsPublic,
req.IsSlim,
)
if err != nil {
h.logger.Error("创建材质失败",
zap.Int64("user_id", userID),
zap.String("name", req.Name),
zap.Error(err),
)
RespondBadRequest(c, err.Error(), nil)
return
}
RespondSuccess(c, TextureToTextureInfo(texture))
}
// Get 获取材质详情
func (h *TextureHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)

View File

@@ -102,44 +102,66 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
RespondSuccess(c, UserToUserInfo(updatedUser))
}
// GenerateAvatarUploadURL 生成头像上传URL
func (h *UserHandler) GenerateAvatarUploadURL(c *gin.Context) {
// UploadAvatar 直接上传头像文件
func (h *UserHandler) UploadAvatar(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.GenerateAvatarUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "请求参数错误", err)
// 解析multipart表单
if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB
RespondBadRequest(c, "解析表单失败", err)
return
}
if h.container.Storage == nil {
RespondServerError(c, "存储服务不可用", nil)
return
}
result, err := h.container.UploadService.GenerateAvatarUploadURL(c.Request.Context(), userID, req.FileName)
// 获取文件
file, err := c.FormFile("file")
if err != nil {
h.logger.Error("生成头像上传URL失败",
RespondBadRequest(c, "获取文件失败", err)
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
RespondBadRequest(c, "打开文件失败", err)
return
}
defer src.Close()
fileData := make([]byte, file.Size)
if _, err := src.Read(fileData); err != nil {
RespondBadRequest(c, "读取文件失败", err)
return
}
// 调用服务上传头像
avatarURL, err := h.container.UserService.UploadAvatar(c.Request.Context(), userID, fileData, file.Filename)
if err != nil {
h.logger.Error("上传头像失败",
zap.Int64("user_id", userID),
zap.String("file_name", req.FileName),
zap.String("file_name", file.Filename),
zap.Error(err),
)
RespondBadRequest(c, err.Error(), nil)
return
}
RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
AvatarURL: result.FileURL,
ExpiresIn: 900,
// 获取更新后的用户信息
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
RespondNotFound(c, "用户不存在")
return
}
RespondSuccess(c, gin.H{
"avatar_url": avatarURL,
"user": UserToUserInfo(user),
})
}
// UpdateAvatar 更新头像URL
// UpdateAvatar 更新头像URL保留用于外部URL
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {

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

View File

@@ -65,19 +65,6 @@ type ChangeEmailRequest struct {
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
}
// GenerateAvatarUploadURLRequest 生成头像上传URL请求
type GenerateAvatarUploadURLRequest struct {
FileName string `json:"file_name" binding:"required" example:"avatar.png"`
}
// GenerateAvatarUploadURLResponse 生成头像上传URL响应
type GenerateAvatarUploadURLResponse struct {
PostURL string `json:"post_url" example:"https://rustfs.example.com/avatars"`
FormData map[string]string `json:"form_data"`
AvatarURL string `json:"avatar_url" example:"https://rustfs.example.com/avatars/user_1/xxx.png"`
ExpiresIn int `json:"expires_in" example:"900"` // 秒
}
// CreateProfileRequest 创建档案请求
type CreateProfileRequest struct {
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
@@ -90,20 +77,6 @@ type UpdateTextureRequest struct {
IsPublic *bool `json:"is_public" example:"true"`
}
// GenerateTextureUploadURLRequest 生成材质上传URL请求
type GenerateTextureUploadURLRequest struct {
FileName string `json:"file_name" binding:"required" example:"skin.png"`
TextureType TextureType `json:"texture_type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
}
// GenerateTextureUploadURLResponse 生成材质上传URL响应
type GenerateTextureUploadURLResponse struct {
PostURL string `json:"post_url" example:"https://rustfs.example.com/textures"`
FormData map[string]string `json:"form_data"`
TextureURL string `json:"texture_url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
ExpiresIn int `json:"expires_in" example:"900"` // 秒
}
// LoginResponse 登录响应
type LoginResponse struct {
Token string `json:"token"`
@@ -177,18 +150,6 @@ type UploadURLResponse struct {
ExpiresIn int `json:"expires_in"`
}
// CreateTextureRequest 创建材质请求
type CreateTextureRequest struct {
Name string `json:"name" binding:"required,min=1,max=100" example:"My Cool Skin"`
Description string `json:"description" binding:"max=500" example:"A very cool skin"`
Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
URL string `json:"url" binding:"required,url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
Hash string `json:"hash" binding:"required,len=64" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" binding:"required,min=1" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
IsSlim bool `json:"is_slim" example:"false"` // Alex模型(细臂)为trueSteve模型(粗臂)为false
}
// SearchTextureRequest 搜索材质请求
type SearchTextureRequest struct {
PaginationRequest

View File

@@ -21,7 +21,6 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
Casbin CasbinConfig `mapstructure:"casbin"`
Log LogConfig `mapstructure:"log"`
Upload UploadConfig `mapstructure:"upload"`
Email EmailConfig `mapstructure:"email"`
Security SecurityConfig `mapstructure:"security"`
}
@@ -99,14 +98,6 @@ type LogConfig struct {
Compress bool `mapstructure:"compress"`
}
// UploadConfig 文件上传配置
type UploadConfig struct {
MaxSize int64 `mapstructure:"max_size"`
AllowedTypes []string `mapstructure:"allowed_types"`
TextureMaxSize int64 `mapstructure:"texture_max_size"`
AvatarMaxSize int64 `mapstructure:"avatar_max_size"`
}
// EmailConfig 邮件配置
type EmailConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -203,12 +194,6 @@ func setDefaults() {
viper.SetDefault("log.max_age", 28)
viper.SetDefault("log.compress", true)
// 文件上传默认配置
viper.SetDefault("upload.max_size", 10485760)
viper.SetDefault("upload.texture_max_size", 2097152)
viper.SetDefault("upload.avatar_max_size", 1048576)
viper.SetDefault("upload.allowed_types", []string{"image/png", "image/jpeg"})
// 邮件默认配置
viper.SetDefault("email.enabled", false)
viper.SetDefault("email.smtp_port", 587)
@@ -370,25 +355,6 @@ func overrideFromEnv(config *Config) {
}
}
// 处理文件上传配置
if maxSize := os.Getenv("UPLOAD_MAX_SIZE"); maxSize != "" {
if val, err := strconv.ParseInt(maxSize, 10, 64); err == nil {
config.Upload.MaxSize = val
}
}
if textureMaxSize := os.Getenv("UPLOAD_TEXTURE_MAX_SIZE"); textureMaxSize != "" {
if val, err := strconv.ParseInt(textureMaxSize, 10, 64); err == nil {
config.Upload.TextureMaxSize = val
}
}
if avatarMaxSize := os.Getenv("UPLOAD_AVATAR_MAX_SIZE"); avatarMaxSize != "" {
if val, err := strconv.ParseInt(avatarMaxSize, 10, 64); err == nil {
config.Upload.AvatarMaxSize = val
}
}
// 处理邮件配置
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"

View File

@@ -10,9 +10,12 @@ REQUIRED_VARS=(
"DATABASE_USERNAME"
"DATABASE_PASSWORD"
"DATABASE_NAME"
"REDIS_HOST"
"RUSTFS_ENDPOINT"
"RUSTFS_ACCESS_KEY"
"RUSTFS_SECRET_KEY"
"RUSTFS_BUCKET_TEXTURES"
"RUSTFS_BUCKET_AVATARS"
"JWT_SECRET"
)
@@ -26,7 +29,9 @@ fi
echo "✅ .env 文件存在"
# 加载.env文件
set -a
source .env 2>/dev/null
set +a
# 检查必需的环境变量
missing_vars=()
@@ -51,8 +56,10 @@ echo "✅ 所有必需的环境变量都已设置"
# 检查关键配置的合理性
echo ""
echo "📋 当前配置概览:"
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME"
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:${DATABASE_PORT:-5432}/$DATABASE_NAME"
echo " Redis: $REDIS_HOST:${REDIS_PORT:-6379}"
echo " RustFS: $RUSTFS_ENDPOINT"
echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS"
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
# 检查JWT密钥长度
@@ -65,11 +72,11 @@ if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
echo "⚠️ 使用的是默认JWT密钥生产环境中请更改"
fi
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ]; then
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ] || [ "$DATABASE_PASSWORD" = "carrotskin123" ]; then
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
fi
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ]; then
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ] || [ "$RUSTFS_ACCESS_KEY" = "rustfsadmin" ]; then
echo "⚠️ 使用的是默认RustFS凭证生产环境中请更改"
fi

View File

@@ -1,28 +0,0 @@
#!/bin/bash
# CarrotSkin 开发环境启动脚本
echo "🚀 启动 CarrotSkin 开发环境..."
# 检查配置文件
if [ ! -f "configs/config.yaml" ]; then
echo "📝 复制配置文件..."
cp configs/config.yaml.example configs/config.yaml
echo "⚠️ 请编辑 configs/config.yaml 文件配置数据库和其他服务连接信息"
fi
# 检查依赖
echo "📦 检查依赖..."
go mod tidy
# 生成Swagger文档
echo "📚 生成Swagger文档..."
if command -v swag &> /dev/null; then
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
else
echo "⚠️ swag工具未安装请运行: go install github.com/swaggo/swag/cmd/swag@latest"
fi
# 启动应用
echo "🎯 启动应用..."
go run cmd/server/main.go