Compare commits
4 Commits
master
...
2c9c6ecfc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c9c6ecfc0 | ||
|
|
c5db489d72 | ||
| d952ddd4ea | |||
| e761ff5be5 |
@@ -32,8 +32,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
_ "carrotskin/docs" // Swagger docs
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -87,22 +87,28 @@ func ProfilesToProfileInfos(profiles []*model.Profile) []*types.ProfileInfo {
|
|||||||
|
|
||||||
// TextureToTextureInfo 将 Texture 模型转换为 TextureInfo 响应
|
// TextureToTextureInfo 将 Texture 模型转换为 TextureInfo 响应
|
||||||
func TextureToTextureInfo(texture *model.Texture) *types.TextureInfo {
|
func TextureToTextureInfo(texture *model.Texture) *types.TextureInfo {
|
||||||
|
uploaderUsername := ""
|
||||||
|
if texture.Uploader != nil {
|
||||||
|
uploaderUsername = texture.Uploader.Username
|
||||||
|
}
|
||||||
|
|
||||||
return &types.TextureInfo{
|
return &types.TextureInfo{
|
||||||
ID: texture.ID,
|
ID: texture.ID,
|
||||||
UploaderID: texture.UploaderID,
|
UploaderID: texture.UploaderID,
|
||||||
Name: texture.Name,
|
UploaderUsername: uploaderUsername,
|
||||||
Description: texture.Description,
|
Name: texture.Name,
|
||||||
Type: types.TextureType(texture.Type),
|
Description: texture.Description,
|
||||||
URL: texture.URL,
|
Type: types.TextureType(texture.Type),
|
||||||
Hash: texture.Hash,
|
URL: texture.URL,
|
||||||
Size: texture.Size,
|
Hash: texture.Hash,
|
||||||
IsPublic: texture.IsPublic,
|
Size: texture.Size,
|
||||||
DownloadCount: texture.DownloadCount,
|
IsPublic: texture.IsPublic,
|
||||||
FavoriteCount: texture.FavoriteCount,
|
DownloadCount: texture.DownloadCount,
|
||||||
IsSlim: texture.IsSlim,
|
FavoriteCount: texture.FavoriteCount,
|
||||||
Status: texture.Status,
|
IsSlim: texture.IsSlim,
|
||||||
CreatedAt: texture.CreatedAt,
|
Status: texture.Status,
|
||||||
UpdatedAt: texture.UpdatedAt,
|
CreatedAt: texture.CreatedAt,
|
||||||
|
UpdatedAt: texture.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ func (r *textureRepository) FindByID(ctx context.Context, id int64) (*model.Text
|
|||||||
|
|
||||||
func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model.Texture, error) {
|
func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model.Texture, error) {
|
||||||
var texture model.Texture
|
var texture model.Texture
|
||||||
err := r.db.WithContext(ctx).Where("hash = ?", hash).First(&texture).Error
|
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ?", hash).First(&texture).Error
|
||||||
return handleNotFoundResult(&texture, err)
|
return handleNotFoundResult(&texture, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *textureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
|
func (r *textureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
|
||||||
var texture model.Texture
|
var texture model.Texture
|
||||||
err := r.db.WithContext(ctx).Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
|
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
|
||||||
return handleNotFoundResult(&texture, err)
|
return handleNotFoundResult(&texture, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
|
|||||||
if texture.Status == -1 {
|
if texture.Status == -1 {
|
||||||
return nil, errors.New("材质已删除")
|
return nil, errors.New("材质已删除")
|
||||||
}
|
}
|
||||||
|
// 如果缓存中没有 Uploader 信息,重新查询数据库
|
||||||
|
if texture.Uploader == nil {
|
||||||
|
texture2, err := s.textureRepo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if texture2 == nil {
|
||||||
|
return nil, ErrTextureNotFound
|
||||||
|
}
|
||||||
|
if texture2.Status == -1 {
|
||||||
|
return nil, errors.New("材质已删除")
|
||||||
|
}
|
||||||
|
// 更新缓存
|
||||||
|
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
|
||||||
|
return texture2, nil
|
||||||
|
}
|
||||||
return &texture, nil
|
return &texture, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +381,8 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
|
|||||||
// 清除用户的 texture 列表缓存(所有分页)
|
// 清除用户的 texture 列表缓存(所有分页)
|
||||||
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
||||||
|
|
||||||
return texture, nil
|
// 重新查询以预加载 Uploader 关联
|
||||||
|
return s.textureRepo.FindByID(ctx, texture.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTextureTypeInternal 解析材质类型
|
// parseTextureTypeInternal 解析材质类型
|
||||||
|
|||||||
@@ -121,21 +121,22 @@ const (
|
|||||||
// TextureInfo 材质信息
|
// TextureInfo 材质信息
|
||||||
// @Description 材质详细信息
|
// @Description 材质详细信息
|
||||||
type TextureInfo struct {
|
type TextureInfo struct {
|
||||||
ID int64 `json:"id" example:"1"`
|
ID int64 `json:"id" example:"1"`
|
||||||
UploaderID int64 `json:"uploader_id" example:"1"`
|
UploaderID int64 `json:"uploader_id" example:"1"`
|
||||||
Name string `json:"name" example:"My Skin"`
|
UploaderUsername string `json:"uploader_username" example:"testuser"`
|
||||||
Description string `json:"description,omitempty" example:"A cool skin"`
|
Name string `json:"name" example:"My Skin"`
|
||||||
Type TextureType `json:"type" example:"SKIN"`
|
Description string `json:"description,omitempty" example:"A cool skin"`
|
||||||
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
|
Type TextureType `json:"type" example:"SKIN"`
|
||||||
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
|
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
|
||||||
Size int `json:"size" example:"2048"`
|
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
|
||||||
IsPublic bool `json:"is_public" example:"true"`
|
Size int `json:"size" example:"2048"`
|
||||||
DownloadCount int `json:"download_count" example:"100"`
|
IsPublic bool `json:"is_public" example:"true"`
|
||||||
FavoriteCount int `json:"favorite_count" example:"50"`
|
DownloadCount int `json:"download_count" example:"100"`
|
||||||
IsSlim bool `json:"is_slim" example:"false"`
|
FavoriteCount int `json:"favorite_count" example:"50"`
|
||||||
Status int16 `json:"status" example:"1"`
|
IsSlim bool `json:"is_slim" example:"false"`
|
||||||
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
Status int16 `json:"status" example:"1"`
|
||||||
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileInfo 角色信息
|
// ProfileInfo 角色信息
|
||||||
|
|||||||
@@ -131,13 +131,16 @@ type SecurityConfig struct {
|
|||||||
// Load 加载配置 - 完全从环境变量加载,不依赖YAML文件
|
// Load 加载配置 - 完全从环境变量加载,不依赖YAML文件
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
// 加载.env文件(如果存在)
|
// 加载.env文件(如果存在)
|
||||||
_ = godotenv.Load(".env")
|
if err := godotenv.Load(".env"); err != nil {
|
||||||
|
fmt.Printf("[Config] 注意: 未加载 .env 文件 (原因: %v)\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[Config] 成功加载 .env 文件")
|
||||||
|
}
|
||||||
|
|
||||||
// 设置默认值
|
// 设置默认值
|
||||||
setDefaults()
|
setDefaults()
|
||||||
|
|
||||||
// 设置环境变量前缀
|
// 自动读取环境变量(不设置前缀,因为 BindEnv 已经明确指定了变量名)
|
||||||
viper.SetEnvPrefix("CARROTSKIN")
|
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
// 手动设置环境变量映射
|
// 手动设置环境变量映射
|
||||||
@@ -152,6 +155,20 @@ func Load() (*Config, error) {
|
|||||||
// 从环境变量中覆盖配置
|
// 从环境变量中覆盖配置
|
||||||
overrideFromEnv(&config)
|
overrideFromEnv(&config)
|
||||||
|
|
||||||
|
// 打印关键配置加载状态
|
||||||
|
fmt.Println("==================================================")
|
||||||
|
fmt.Println(" CarrotSkin Configuration Check ")
|
||||||
|
fmt.Println("==================================================")
|
||||||
|
fmt.Printf("Server Port: %s\n", config.Server.Port)
|
||||||
|
fmt.Printf("Database Host: %s\n", config.Database.Host)
|
||||||
|
fmt.Printf("Redis Host: %s\n", config.Redis.Host)
|
||||||
|
fmt.Printf("Environment: %s\n", config.Environment)
|
||||||
|
|
||||||
|
if config.Database.Host == "localhost" && os.Getenv("DATABASE_HOST") != "" && os.Getenv("DATABASE_HOST") != "localhost" {
|
||||||
|
fmt.Printf("[Warning] Database Host is 'localhost' but env DATABASE_HOST is set to '%s'. Viper binding might have failed.\n", os.Getenv("DATABASE_HOST"))
|
||||||
|
}
|
||||||
|
fmt.Println("==================================================")
|
||||||
|
|
||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +319,7 @@ func setupEnvMappings() {
|
|||||||
|
|
||||||
// overrideFromEnv 从环境变量中覆盖配置
|
// overrideFromEnv 从环境变量中覆盖配置
|
||||||
func overrideFromEnv(config *Config) {
|
func overrideFromEnv(config *Config) {
|
||||||
|
|
||||||
// 处理RustFS存储桶配置
|
// 处理RustFS存储桶配置
|
||||||
if texturesBucket := os.Getenv("RUSTFS_BUCKET_TEXTURES"); texturesBucket != "" {
|
if texturesBucket := os.Getenv("RUSTFS_BUCKET_TEXTURES"); texturesBucket != "" {
|
||||||
if config.RustFS.Buckets == nil {
|
if config.RustFS.Buckets == nil {
|
||||||
@@ -342,6 +360,24 @@ func overrideFromEnv(config *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理Redis基本配置
|
||||||
|
if host := os.Getenv("REDIS_HOST"); host != "" {
|
||||||
|
config.Redis.Host = host
|
||||||
|
}
|
||||||
|
if port := os.Getenv("REDIS_PORT"); port != "" {
|
||||||
|
if val, err := strconv.Atoi(port); err == nil {
|
||||||
|
config.Redis.Port = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if password := os.Getenv("REDIS_PASSWORD"); password != "" {
|
||||||
|
config.Redis.Password = password
|
||||||
|
}
|
||||||
|
if database := os.Getenv("REDIS_DATABASE"); database != "" {
|
||||||
|
if val, err := strconv.Atoi(database); err == nil {
|
||||||
|
config.Redis.Database = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理Redis连接池配置
|
// 处理Redis连接池配置
|
||||||
if poolSize := os.Getenv("REDIS_POOL_SIZE"); poolSize != "" {
|
if poolSize := os.Getenv("REDIS_POOL_SIZE"); poolSize != "" {
|
||||||
if val, err := strconv.Atoi(poolSize); err == nil {
|
if val, err := strconv.Atoi(poolSize); err == nil {
|
||||||
|
|||||||
@@ -79,57 +79,6 @@ func (s *StorageClient) GetBucket(name string) (string, error) {
|
|||||||
return bucket, nil
|
return bucket, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeneratePresignedURL 生成预签名上传URL (PUT方法)
|
|
||||||
func (s *StorageClient) GeneratePresignedURL(ctx context.Context, bucketName, objectName string, expires time.Duration) (string, error) {
|
|
||||||
url, err := s.client.PresignedPutObject(ctx, bucketName, objectName, expires)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("生成预签名URL失败: %w", err)
|
|
||||||
}
|
|
||||||
return url.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PresignedPostPolicyResult 预签名POST策略结果
|
|
||||||
type PresignedPostPolicyResult struct {
|
|
||||||
PostURL string // POST的URL
|
|
||||||
FormData map[string]string // 表单数据
|
|
||||||
FileURL string // 文件的最终访问URL
|
|
||||||
}
|
|
||||||
|
|
||||||
// GeneratePresignedPostURL 生成预签名POST URL (支持表单上传)
|
|
||||||
// 注意:使用时必须确保file字段是表单的最后一个字段
|
|
||||||
func (s *StorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*PresignedPostPolicyResult, error) {
|
|
||||||
// 创建上传策略
|
|
||||||
policy := minio.NewPostPolicy()
|
|
||||||
|
|
||||||
// 设置策略的基本信息
|
|
||||||
policy.SetBucket(bucketName)
|
|
||||||
policy.SetKey(objectName)
|
|
||||||
policy.SetExpires(time.Now().UTC().Add(expires))
|
|
||||||
|
|
||||||
// 设置文件大小限制
|
|
||||||
if err := policy.SetContentLengthRange(minSize, maxSize); err != nil {
|
|
||||||
return nil, fmt.Errorf("设置文件大小限制失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用MinIO客户端和策略生成预签名的POST URL和表单数据
|
|
||||||
postURL, formData, err := s.client.PresignedPostPolicy(ctx, policy)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("生成预签名POST URL失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除form_data中多余的bucket字段(MinIO Go SDK可能会添加这个字段,但会导致签名错误)
|
|
||||||
// 注意:在Go中直接delete不存在的key是安全的
|
|
||||||
delete(formData, "bucket")
|
|
||||||
|
|
||||||
// 使用配置的公开访问URL构造文件的永久访问URL
|
|
||||||
fileURL := s.BuildFileURL(bucketName, objectName)
|
|
||||||
|
|
||||||
return &PresignedPostPolicyResult{
|
|
||||||
PostURL: postURL.String(),
|
|
||||||
FormData: formData,
|
|
||||||
FileURL: fileURL,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildFileURL 构建文件的公开访问URL
|
// BuildFileURL 构建文件的公开访问URL
|
||||||
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {
|
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"carrotskin/pkg/config"
|
"carrotskin/pkg/config"
|
||||||
|
|
||||||
@@ -41,31 +39,3 @@ func TestNewStorage_SkipConnectWhenNoCreds(t *testing.T) {
|
|||||||
t.Fatalf("NewStorage should not error when creds empty: %v", err)
|
t.Fatalf("NewStorage should not error when creds empty: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPresignedHelpers_WithNilClient(t *testing.T) {
|
|
||||||
s := &StorageClient{
|
|
||||||
client: (*minio.Client)(nil),
|
|
||||||
buckets: map[string]string{"textures": "tex-bkt"},
|
|
||||||
publicURL: "http://localhost:9000",
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// 预期会panic(nil client),用recover捕获
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r == nil {
|
|
||||||
t.Fatalf("GeneratePresignedURL expected panic with nil client")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
_, _ = s.GeneratePresignedURL(ctx, "tex-bkt", "obj", time.Minute)
|
|
||||||
}()
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r == nil {
|
|
||||||
t.Fatalf("GeneratePresignedPostURL expected panic with nil client")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
_, _ = s.GeneratePresignedPostURL(ctx, "tex-bkt", "obj", 0, 10, time.Minute)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user