统一文件上传方式为直接上传,更新环境变量示例
This commit is contained in:
@@ -1,18 +1,16 @@
|
|||||||
# ==================== CarrotSkin Docker 环境配置示例 ====================
|
# ==================== CarrotSkin Docker 环境配置示例 ====================
|
||||||
# 复制此文件为 .env 后修改配置值
|
# 复制此文件为 .env 后修改配置值
|
||||||
|
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
|
||||||
|
|
||||||
# ==================== 服务配置 ====================
|
# ==================== 服务配置 ====================
|
||||||
# 应用端口
|
# 应用对外端口
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
# 运行模式: debug, release, test
|
# 运行模式: debug, release, test
|
||||||
SERVER_MODE=release
|
SERVER_MODE=release
|
||||||
# API 根路径 (用于反向代理,如 /api)
|
|
||||||
SERVER_BASE_PATH=
|
|
||||||
# 公开访问地址 (用于生成回调URL、邮件链接等)
|
|
||||||
PUBLIC_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
DB_PASSWORD=carrotskin123
|
# 数据库密码,生产环境务必修改
|
||||||
|
DATABASE_PASSWORD=carrotskin123
|
||||||
|
|
||||||
# ==================== Redis 配置 ====================
|
# ==================== Redis 配置 ====================
|
||||||
# 留空表示不设置密码
|
# 留空表示不设置密码
|
||||||
@@ -25,23 +23,26 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
|||||||
# ==================== 存储配置 (RustFS S3兼容) ====================
|
# ==================== 存储配置 (RustFS S3兼容) ====================
|
||||||
# 内部访问地址 (容器间通信)
|
# 内部访问地址 (容器间通信)
|
||||||
RUSTFS_ENDPOINT=rustfs:9000
|
RUSTFS_ENDPOINT=rustfs:9000
|
||||||
|
# 公开访问地址 (用于生成文件URL,供外部浏览器访问)
|
||||||
|
# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage
|
||||||
|
RUSTFS_PUBLIC_URL=http://localhost:9000
|
||||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||||
RUSTFS_SECRET_KEY=rustfsadmin123
|
RUSTFS_SECRET_KEY=rustfsadmin123
|
||||||
RUSTFS_USE_SSL=false
|
RUSTFS_USE_SSL=false
|
||||||
|
|
||||||
# 存储桶配置
|
# 存储桶配置
|
||||||
RUSTFS_BUCKET_TEXTURES=carrotskin
|
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
|
||||||
RUSTFS_BUCKET_AVATARS=carrotskin
|
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
|
||||||
|
|
||||||
# 公开访问地址 (用于生成文件URL,供外部浏览器访问)
|
# ==================== 安全配置 ====================
|
||||||
# 示例:
|
# CORS 允许的来源,多个用逗号分隔
|
||||||
# 直接访问: http://localhost:9000
|
SECURITY_ALLOWED_ORIGINS=*
|
||||||
# 反向代理: https://example.com/storage
|
# 允许的头像/材质URL域名,多个用逗号分隔
|
||||||
RUSTFS_PUBLIC_URL=http://localhost:9000
|
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
|
||||||
|
|
||||||
# ==================== 邮件配置 (可选) ====================
|
# ==================== 邮件配置 ====================
|
||||||
SMTP_HOST=
|
EMAIL_ENABLED=false
|
||||||
SMTP_PORT=587
|
EMAIL_SMTP_HOST=
|
||||||
SMTP_USER=
|
EMAIL_SMTP_PORT=587
|
||||||
SMTP_PASSWORD=
|
EMAIL_USERNAME=
|
||||||
SMTP_FROM=
|
EMAIL_PASSWORD=
|
||||||
|
EMAIL_FROM_NAME=CarrotSkin
|
||||||
|
|||||||
21
.env.example
21
.env.example
@@ -23,6 +23,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
|
|||||||
DATABASE_MAX_IDLE_CONNS=10
|
DATABASE_MAX_IDLE_CONNS=10
|
||||||
DATABASE_MAX_OPEN_CONNS=100
|
DATABASE_MAX_OPEN_CONNS=100
|
||||||
DATABASE_CONN_MAX_LIFETIME=1h
|
DATABASE_CONN_MAX_LIFETIME=1h
|
||||||
|
DATABASE_CONN_MAX_IDLE_TIME=10m
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Redis配置
|
# Redis配置
|
||||||
@@ -37,6 +38,7 @@ REDIS_POOL_SIZE=10
|
|||||||
# RustFS对象存储配置 (S3兼容)
|
# RustFS对象存储配置 (S3兼容)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
RUSTFS_ENDPOINT=127.0.0.1:9000
|
RUSTFS_ENDPOINT=127.0.0.1:9000
|
||||||
|
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
|
||||||
RUSTFS_ACCESS_KEY=your_access_key
|
RUSTFS_ACCESS_KEY=your_access_key
|
||||||
RUSTFS_SECRET_KEY=your_secret_key
|
RUSTFS_SECRET_KEY=your_secret_key
|
||||||
RUSTFS_USE_SSL=false
|
RUSTFS_USE_SSL=false
|
||||||
@@ -55,26 +57,17 @@ JWT_EXPIRE_HOURS=168
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=json
|
LOG_FORMAT=json
|
||||||
LOG_OUTPUT=logs/app.log
|
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
|
# CORS 允许的来源,多个用逗号分隔
|
||||||
LOGIN_LOCK_DURATION=30m
|
SECURITY_ALLOWED_ORIGINS=*
|
||||||
|
# 允许的头像/材质URL域名,多个用逗号分隔
|
||||||
|
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 邮件配置(可选)
|
# 邮件配置
|
||||||
# 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465
|
# 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
EMAIL_ENABLED=false
|
EMAIL_ENABLED=false
|
||||||
|
|||||||
@@ -13,40 +13,43 @@ services:
|
|||||||
- "${APP_PORT:-8080}:8080"
|
- "${APP_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
- SERVER_PORT=8080
|
- SERVER_PORT=:8080
|
||||||
- SERVER_MODE=${SERVER_MODE:-release}
|
- SERVER_MODE=${SERVER_MODE:-release}
|
||||||
- SERVER_BASE_PATH=${SERVER_BASE_PATH:-}
|
|
||||||
# 公开访问地址 (用于生成回调URL、邮件链接等)
|
|
||||||
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080}
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
- DB_HOST=postgres
|
- DATABASE_DRIVER=postgres
|
||||||
- DB_PORT=5432
|
- DATABASE_HOST=postgres
|
||||||
- DB_USER=carrotskin
|
- DATABASE_PORT=5432
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-carrotskin123}
|
- DATABASE_USERNAME=carrotskin
|
||||||
- DB_NAME=carrotskin
|
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
|
||||||
- DB_SSLMODE=disable
|
- DATABASE_NAME=carrotskin
|
||||||
|
- DATABASE_SSL_MODE=disable
|
||||||
|
- DATABASE_TIMEZONE=Asia/Shanghai
|
||||||
# Redis 配置
|
# Redis 配置
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||||
- REDIS_DB=0
|
- REDIS_DATABASE=0
|
||||||
# JWT 配置
|
# JWT 配置
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||||
- JWT_EXPIRE_HOURS=24
|
- JWT_EXPIRE_HOURS=168
|
||||||
# 存储配置 (RustFS S3兼容)
|
# 存储配置 (RustFS S3兼容)
|
||||||
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
|
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
|
||||||
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
|
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
|
||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
||||||
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
|
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
|
||||||
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
|
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
|
||||||
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrotskin}
|
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
|
||||||
# 邮件配置 (可选)
|
# 安全配置
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
- SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1}
|
||||||
- SMTP_USER=${SMTP_USER:-}
|
# 邮件配置
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
- EMAIL_ENABLED=${EMAIL_ENABLED:-false}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- 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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -68,7 +71,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=carrotskin
|
- POSTGRES_USER=carrotskin
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-carrotskin123}
|
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
|
||||||
- POSTGRES_DB=carrotskin
|
- POSTGRES_DB=carrotskin
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
@@ -148,14 +151,19 @@ services:
|
|||||||
echo '等待 RustFS 启动...';
|
echo '等待 RustFS 启动...';
|
||||||
sleep 5;
|
sleep 5;
|
||||||
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
|
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
|
||||||
mc mb myrustfs/$${RUSTFS_BUCKET} --ignore-existing;
|
echo '创建材质存储桶...';
|
||||||
mc anonymous set download myrustfs/$${RUSTFS_BUCKET};
|
mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing;
|
||||||
echo '存储桶 $${RUSTFS_BUCKET} 创建完成,已设置公开读取权限';
|
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:
|
environment:
|
||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
- 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:
|
networks:
|
||||||
- carrotskin-network
|
- carrotskin-network
|
||||||
profiles:
|
profiles:
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ type Container struct {
|
|||||||
TokenService service.TokenService
|
TokenService service.TokenService
|
||||||
YggdrasilService service.YggdrasilService
|
YggdrasilService service.YggdrasilService
|
||||||
VerificationService service.VerificationService
|
VerificationService service.VerificationService
|
||||||
UploadService service.UploadService
|
|
||||||
SecurityService service.SecurityService
|
SecurityService service.SecurityService
|
||||||
CaptchaService service.CaptchaService
|
CaptchaService service.CaptchaService
|
||||||
SignatureService *service.SignatureService
|
SignatureService *service.SignatureService
|
||||||
@@ -87,7 +86,7 @@ func NewContainer(
|
|||||||
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
||||||
|
|
||||||
// 初始化Service(注入缓存管理器)
|
// 初始化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.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
||||||
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
||||||
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
|
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
|
||||||
@@ -107,7 +106,6 @@ func NewContainer(
|
|||||||
|
|
||||||
// 初始化其他服务
|
// 初始化其他服务
|
||||||
c.SecurityService = service.NewSecurityService(redisClient)
|
c.SecurityService = service.NewSecurityService(redisClient)
|
||||||
c.UploadService = service.NewUploadService(storageClient)
|
|
||||||
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
|
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
|
||||||
|
|
||||||
// 初始化VerificationService(需要email.Service)
|
// 初始化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 设置安全服务
|
// WithSecurityService 设置安全服务
|
||||||
func WithSecurityService(svc service.SecurityService) Option {
|
func WithSecurityService(svc service.SecurityService) Option {
|
||||||
return func(c *Container) {
|
return func(c *Container) {
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW
|
|||||||
userGroup.PUT("/profile", h.UpdateProfile)
|
userGroup.PUT("/profile", h.UpdateProfile)
|
||||||
|
|
||||||
// 头像相关
|
// 头像相关
|
||||||
userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL)
|
userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
|
||||||
userGroup.PUT("/avatar", h.UpdateAvatar)
|
userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL(外部URL)
|
||||||
|
|
||||||
// 更换邮箱
|
// 更换邮箱
|
||||||
userGroup.POST("/change-email", h.ChangeEmail)
|
userGroup.POST("/change-email", h.ChangeEmail)
|
||||||
@@ -122,9 +122,7 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
|
|||||||
textureAuth := textureGroup.Group("")
|
textureAuth := textureGroup.Group("")
|
||||||
textureAuth.Use(middleware.AuthMiddleware(jwtService))
|
textureAuth.Use(middleware.AuthMiddleware(jwtService))
|
||||||
{
|
{
|
||||||
textureAuth.POST("/upload", h.Upload) // 直接上传文件
|
textureAuth.POST("/upload", h.Upload) // 直接上传文件
|
||||||
textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL(保留兼容性)
|
|
||||||
textureAuth.POST("", h.Create) // 创建材质记录(配合预签名URL使用)
|
|
||||||
textureAuth.PUT("/:id", h.Update)
|
textureAuth.PUT("/:id", h.Update)
|
||||||
textureAuth.DELETE("/:id", h.Delete)
|
textureAuth.DELETE("/:id", h.Delete)
|
||||||
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
|
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
|
||||||
|
|||||||
@@ -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 获取材质详情
|
// Get 获取材质详情
|
||||||
func (h *TextureHandler) Get(c *gin.Context) {
|
func (h *TextureHandler) Get(c *gin.Context) {
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -102,44 +102,66 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|||||||
RespondSuccess(c, UserToUserInfo(updatedUser))
|
RespondSuccess(c, UserToUserInfo(updatedUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAvatarUploadURL 生成头像上传URL
|
// UploadAvatar 直接上传头像文件
|
||||||
func (h *UserHandler) GenerateAvatarUploadURL(c *gin.Context) {
|
func (h *UserHandler) UploadAvatar(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req types.GenerateAvatarUploadURLRequest
|
// 解析multipart表单
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB
|
||||||
RespondBadRequest(c, "请求参数错误", err)
|
RespondBadRequest(c, "解析表单失败", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.container.Storage == nil {
|
// 获取文件
|
||||||
RespondServerError(c, "存储服务不可用", nil)
|
file, err := c.FormFile("file")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.container.UploadService.GenerateAvatarUploadURL(c.Request.Context(), userID, req.FileName)
|
|
||||||
if err != nil {
|
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.Int64("user_id", userID),
|
||||||
zap.String("file_name", req.FileName),
|
zap.String("file_name", file.Filename),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
RespondBadRequest(c, err.Error(), nil)
|
RespondBadRequest(c, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{
|
// 获取更新后的用户信息
|
||||||
PostURL: result.PostURL,
|
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
|
||||||
FormData: result.FormData,
|
if err != nil || user == nil {
|
||||||
AvatarURL: result.FileURL,
|
RespondNotFound(c, "用户不存在")
|
||||||
ExpiresIn: 900,
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RespondSuccess(c, gin.H{
|
||||||
|
"avatar_url": avatarURL,
|
||||||
|
"user": UserToUserInfo(user),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAvatar 更新头像URL
|
// UpdateAvatar 更新头像URL(保留用于外部URL)
|
||||||
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
|
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ type UserService interface {
|
|||||||
ResetPassword(ctx context.Context, email, newPassword string) error
|
ResetPassword(ctx context.Context, email, newPassword string) error
|
||||||
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
||||||
|
|
||||||
|
// 头像上传
|
||||||
|
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
|
||||||
|
|
||||||
// URL验证
|
// URL验证
|
||||||
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
||||||
|
|
||||||
@@ -55,8 +58,7 @@ type ProfileService interface {
|
|||||||
// TextureService 材质服务接口
|
// TextureService 材质服务接口
|
||||||
type TextureService interface {
|
type TextureService interface {
|
||||||
// 材质CRUD
|
// 材质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)
|
GetByID(ctx context.Context, id int64) (*model.Texture, error)
|
||||||
GetByHash(ctx context.Context, hash string) (*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)
|
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)
|
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服务接口
|
// YggdrasilService Yggdrasil服务接口
|
||||||
type YggdrasilService interface {
|
type YggdrasilService interface {
|
||||||
// 用户认证
|
// 用户认证
|
||||||
@@ -211,7 +207,6 @@ type Services struct {
|
|||||||
Token TokenService
|
Token TokenService
|
||||||
Verification VerificationService
|
Verification VerificationService
|
||||||
Captcha CaptchaService
|
Captcha CaptchaService
|
||||||
Upload UploadService
|
|
||||||
Yggdrasil YggdrasilService
|
Yggdrasil YggdrasilService
|
||||||
Security SecurityService
|
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) {
|
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
|
||||||
// 尝试从缓存获取
|
// 尝试从缓存获取
|
||||||
cacheKey := s.cacheKeys.Texture(id)
|
cacheKey := s.cacheKeys.Texture(id)
|
||||||
|
|||||||
@@ -478,130 +478,6 @@ func boolPtr(b bool) *bool {
|
|||||||
// 使用 Mock 的集成测试
|
// 使用 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
|
// TestTextureServiceImpl_GetByID 测试获取Texture
|
||||||
func TestTextureServiceImpl_GetByID(t *testing.T) {
|
func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||||
textureRepo := NewMockTextureRepository()
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +19,7 @@ import (
|
|||||||
"carrotskin/pkg/config"
|
"carrotskin/pkg/config"
|
||||||
"carrotskin/pkg/database"
|
"carrotskin/pkg/database"
|
||||||
"carrotskin/pkg/redis"
|
"carrotskin/pkg/redis"
|
||||||
|
"carrotskin/pkg/storage"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -28,6 +33,7 @@ type userService struct {
|
|||||||
cache *database.CacheManager
|
cache *database.CacheManager
|
||||||
cacheKeys *database.CacheKeyBuilder
|
cacheKeys *database.CacheKeyBuilder
|
||||||
cacheInv *database.CacheInvalidator
|
cacheInv *database.CacheInvalidator
|
||||||
|
storage *storage.StorageClient
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,7 @@ func NewUserService(
|
|||||||
jwtService *auth.JWTService,
|
jwtService *auth.JWTService,
|
||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
cacheManager *database.CacheManager,
|
cacheManager *database.CacheManager,
|
||||||
|
storageClient *storage.StorageClient,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) UserService {
|
) UserService {
|
||||||
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
||||||
@@ -50,6 +57,7 @@ func NewUserService(
|
|||||||
cache: cacheManager,
|
cache: cacheManager,
|
||||||
cacheKeys: database.NewCacheKeyBuilder(""),
|
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||||
cacheInv: database.NewCacheInvalidator(cacheManager),
|
cacheInv: database.NewCacheInvalidator(cacheManager),
|
||||||
|
storage: storageClient,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +355,67 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
|
|||||||
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
|
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 {
|
func (s *userService) GetMaxProfilesPerUser() int {
|
||||||
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
||||||
if err != nil || config == nil {
|
if err != nil || config == nil {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
|||||||
// 初始化Service
|
// 初始化Service
|
||||||
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), testUser)
|
_ = userRepo.Create(context.Background(), testUser)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user2)
|
_ = userRepo.Create(context.Background(), user2)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
|||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
|||||||
|
|
||||||
// 未配置时走默认值
|
// 未配置时走默认值
|
||||||
cacheManager := NewMockCacheManager()
|
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 {
|
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,19 +65,6 @@ type ChangeEmailRequest struct {
|
|||||||
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
|
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 创建档案请求
|
// CreateProfileRequest 创建档案请求
|
||||||
type CreateProfileRequest struct {
|
type CreateProfileRequest struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
|
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"`
|
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 登录响应
|
// LoginResponse 登录响应
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -177,18 +150,6 @@ type UploadURLResponse struct {
|
|||||||
ExpiresIn int `json:"expires_in"`
|
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模型(细臂)为true,Steve模型(粗臂)为false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTextureRequest 搜索材质请求
|
// SearchTextureRequest 搜索材质请求
|
||||||
type SearchTextureRequest struct {
|
type SearchTextureRequest struct {
|
||||||
PaginationRequest
|
PaginationRequest
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type Config struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
Security SecurityConfig `mapstructure:"security"`
|
Security SecurityConfig `mapstructure:"security"`
|
||||||
}
|
}
|
||||||
@@ -99,14 +98,6 @@ type LogConfig struct {
|
|||||||
Compress bool `mapstructure:"compress"`
|
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 邮件配置
|
// EmailConfig 邮件配置
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
@@ -203,12 +194,6 @@ func setDefaults() {
|
|||||||
viper.SetDefault("log.max_age", 28)
|
viper.SetDefault("log.max_age", 28)
|
||||||
viper.SetDefault("log.compress", true)
|
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.enabled", false)
|
||||||
viper.SetDefault("email.smtp_port", 587)
|
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 != "" {
|
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||||
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ REQUIRED_VARS=(
|
|||||||
"DATABASE_USERNAME"
|
"DATABASE_USERNAME"
|
||||||
"DATABASE_PASSWORD"
|
"DATABASE_PASSWORD"
|
||||||
"DATABASE_NAME"
|
"DATABASE_NAME"
|
||||||
|
"REDIS_HOST"
|
||||||
"RUSTFS_ENDPOINT"
|
"RUSTFS_ENDPOINT"
|
||||||
"RUSTFS_ACCESS_KEY"
|
"RUSTFS_ACCESS_KEY"
|
||||||
"RUSTFS_SECRET_KEY"
|
"RUSTFS_SECRET_KEY"
|
||||||
|
"RUSTFS_BUCKET_TEXTURES"
|
||||||
|
"RUSTFS_BUCKET_AVATARS"
|
||||||
"JWT_SECRET"
|
"JWT_SECRET"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +29,9 @@ fi
|
|||||||
echo "✅ .env 文件存在"
|
echo "✅ .env 文件存在"
|
||||||
|
|
||||||
# 加载.env文件
|
# 加载.env文件
|
||||||
|
set -a
|
||||||
source .env 2>/dev/null
|
source .env 2>/dev/null
|
||||||
|
set +a
|
||||||
|
|
||||||
# 检查必需的环境变量
|
# 检查必需的环境变量
|
||||||
missing_vars=()
|
missing_vars=()
|
||||||
@@ -51,8 +56,10 @@ echo "✅ 所有必需的环境变量都已设置"
|
|||||||
# 检查关键配置的合理性
|
# 检查关键配置的合理性
|
||||||
echo ""
|
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: $RUSTFS_ENDPOINT"
|
||||||
|
echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS"
|
||||||
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
|
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
|
||||||
|
|
||||||
# 检查JWT密钥长度
|
# 检查JWT密钥长度
|
||||||
@@ -65,11 +72,11 @@ if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
|
|||||||
echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改"
|
echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改"
|
||||||
fi
|
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 "⚠️ 使用的是默认数据库密码,生产环境中请更改"
|
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
|
||||||
fi
|
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凭证,生产环境中请更改"
|
echo "⚠️ 使用的是默认RustFS凭证,生产环境中请更改"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user