diff --git a/go.mod b/go.mod index 4eadf20..41ad466 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.2 require ( + github.com/chai2010/webp v1.4.0 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/joho/godotenv v1.5.1 @@ -27,17 +28,17 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/goccy/go-yaml v1.19.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/minio/crc64nvme v1.1.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/tinylib/msgp v1.3.0 // indirect - go.uber.org/mock v0.5.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.uber.org/mock v0.6.0 // indirect golang.org/x/image v0.33.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect diff --git a/go.sum b/go.sum index 634494a..2c03017 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2N github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= +github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= +github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= +github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -49,6 +53,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= +github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -101,6 +107,8 @@ github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= @@ -118,8 +126,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -152,6 +164,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= @@ -165,6 +179,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 852de64..5806e86 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -108,6 +108,10 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a // 公开路由(无需认证) textureGroup.GET("", h.Search) textureGroup.GET("/:id", h.Get) + textureGroup.GET("/:id/render", h.RenderTexture) // type/front/back/full/head/isometric + textureGroup.GET("/:id/avatar", h.RenderAvatar) // mode=2d/3d + textureGroup.GET("/:id/cape", h.RenderCape) + textureGroup.GET("/:id/preview", h.RenderPreview) // 自动根据类型预览 // 需要认证的路由 textureAuth := textureGroup.Group("") diff --git a/internal/handler/texture_handler.go b/internal/handler/texture_handler.go index e798b0f..2c04b7b 100644 --- a/internal/handler/texture_handler.go +++ b/internal/handler/texture_handler.go @@ -3,6 +3,7 @@ package handler import ( "carrotskin/internal/container" "carrotskin/internal/model" + "carrotskin/internal/service" "carrotskin/internal/types" "strconv" @@ -171,6 +172,98 @@ func (h *TextureHandler) Search(c *gin.Context) { }) } +// RenderTexture 渲染皮肤/披风预览 +func (h *TextureHandler) RenderTexture(c *gin.Context) { + textureID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的材质ID", err) + return + } + renderType := service.RenderType(c.DefaultQuery("type", string(service.RenderTypeIsometric))) + size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256) + format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG))) + + result, err := h.container.TextureRenderService.RenderTexture(c.Request.Context(), textureID, renderType, size, format) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + RespondSuccess(c, toRenderResponse(result)) +} + +// RenderAvatar 渲染头像(2D/3D) +func (h *TextureHandler) RenderAvatar(c *gin.Context) { + textureID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的材质ID", err) + return + } + mode := service.AvatarMode(c.DefaultQuery("mode", string(service.AvatarMode2D))) + size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256) + format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG))) + + result, err := h.container.TextureRenderService.RenderAvatar(c.Request.Context(), textureID, size, mode, format) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + RespondSuccess(c, toRenderResponse(result)) +} + +// RenderCape 渲染披风 +func (h *TextureHandler) RenderCape(c *gin.Context) { + textureID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的材质ID", err) + return + } + size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256) + format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG))) + + result, err := h.container.TextureRenderService.RenderCape(c.Request.Context(), textureID, size, format) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + RespondSuccess(c, toRenderResponse(result)) +} + +// RenderPreview 自动选择预览(皮肤走等距,披风走披风渲染) +func (h *TextureHandler) RenderPreview(c *gin.Context) { + textureID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的材质ID", err) + return + } + size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256) + format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG))) + + result, err := h.container.TextureRenderService.RenderPreview(c.Request.Context(), textureID, size, format) + if err != nil { + RespondBadRequest(c, err.Error(), err) + return + } + RespondSuccess(c, toRenderResponse(result)) +} + +// toRenderResponse 转换为API响应 +func toRenderResponse(r *service.RenderResult) *types.RenderResponse { + if r == nil { + return nil + } + resp := &types.RenderResponse{ + URL: r.URL, + ContentType: r.ContentType, + ETag: r.ETag, + Size: r.Size, + } + if !r.LastModified.IsZero() { + t := r.LastModified + resp.LastModified = &t + } + return resp +} + // Update 更新材质 func (h *TextureHandler) Update(c *gin.Context) { userID, ok := GetUserIDFromContext(c) diff --git a/internal/model/base.go b/internal/model/base.go index a6dae90..490b0c7 100644 --- a/internal/model/base.go +++ b/internal/model/base.go @@ -23,3 +23,9 @@ type BaseModel struct { } + + + + + + diff --git a/internal/repository/yggdrasil_repository.go b/internal/repository/yggdrasil_repository.go index aa053e3..d517614 100644 --- a/internal/repository/yggdrasil_repository.go +++ b/internal/repository/yggdrasil_repository.go @@ -29,3 +29,9 @@ func (r *yggdrasilRepository) GetPasswordByID(ctx context.Context, id int64) (st func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error { return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error } + + + + + + diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 9634bf9..2a03a9d 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -141,6 +141,69 @@ type SecurityService interface { ClearVerifyAttempts(ctx context.Context, email, codeType string) error } +// TextureRenderService 纹理渲染服务接口 +type TextureRenderService interface { + // RenderTexture 渲染纹理为预览图 + RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error) + // RenderTextureFromData 从原始数据渲染纹理 + RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error) + // GetRenderURL 获取渲染图的URL + GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string + // DeleteRenderCache 删除渲染缓存 + DeleteRenderCache(ctx context.Context, textureID int64) error + // RenderAvatar 渲染头像(支持2D/3D模式) + RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error) + // RenderCape 渲染披风 + RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) + // RenderPreview 渲染预览图(类似Blessing Skin的preview功能) + RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) +} + +// RenderType 渲染类型 +type RenderType string + +const ( + RenderTypeFront RenderType = "front" // 正面 + RenderTypeBack RenderType = "back" // 背面 + RenderTypeFull RenderType = "full" // 全身 + RenderTypeHead RenderType = "head" // 头像 + RenderTypeIsometric RenderType = "isometric" // 等距视图 +) + +// ImageFormat 输出格式 +type ImageFormat string + +const ( + ImageFormatPNG ImageFormat = "png" + ImageFormatWEBP ImageFormat = "webp" +) + +// AvatarMode 头像模式 +type AvatarMode string + +const ( + AvatarMode2D AvatarMode = "2d" // 2D头像 + AvatarMode3D AvatarMode = "3d" // 3D头像 +) + +// TextureType 纹理类型 +type TextureType string + +const ( + TextureTypeSteve TextureType = "steve" // Steve皮肤 + TextureTypeAlex TextureType = "alex" // Alex皮肤 + TextureTypeCape TextureType = "cape" // 披风 +) + +// RenderResult 渲染结果(附带缓存/HTTP头信息) +type RenderResult struct { + URL string + ContentType string + ETag string + LastModified time.Time + Size int64 +} + // Services 服务集合 type Services struct { User UserService diff --git a/internal/service/mocks_test.go b/internal/service/mocks_test.go index 6872fcd..aa890d1 100644 --- a/internal/service/mocks_test.go +++ b/internal/service/mocks_test.go @@ -315,6 +315,18 @@ func (m *MockTextureRepository) FindByHash(ctx context.Context, hash string) (*m return nil, nil } +func (m *MockTextureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) { + if m.FailFind { + return nil, errors.New("mock find error") + } + for _, texture := range m.textures { + if texture.Hash == hash && texture.UploaderID == uploaderID { + return texture, nil + } + } + return nil, nil +} + func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) { if m.FailFind { return nil, 0, errors.New("mock find error") diff --git a/internal/service/texture_render_service.go b/internal/service/texture_render_service.go new file mode 100644 index 0000000..aefcb60 --- /dev/null +++ b/internal/service/texture_render_service.go @@ -0,0 +1,701 @@ +package service + +import ( + "bytes" + "carrotskin/internal/model" + "carrotskin/internal/repository" + "carrotskin/pkg/database" + "carrotskin/pkg/storage" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "io" + "net/http" + "time" + + "github.com/chai2010/webp" + "go.uber.org/zap" +) + +// textureRenderService TextureRenderService的实现 +type textureRenderService struct { + textureRepo repository.TextureRepository + storage *storage.StorageClient + cache *database.CacheManager + cacheKeys *database.CacheKeyBuilder + logger *zap.Logger +} + +// NewTextureRenderService 创建TextureRenderService实例 +func NewTextureRenderService( + textureRepo repository.TextureRepository, + storageClient *storage.StorageClient, + cacheManager *database.CacheManager, + logger *zap.Logger, +) TextureRenderService { + return &textureRenderService{ + textureRepo: textureRepo, + storage: storageClient, + cache: cacheManager, + cacheKeys: database.NewCacheKeyBuilder(""), + logger: logger, + } +} + +// RenderTexture 渲染纹理为预览图 +func (s *textureRenderService) RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error) { + // 参数验证 + if size <= 0 || size > 2048 { + return nil, errors.New("渲染尺寸必须在1到2048之间") + } + contentType, err := normalizeFormat(format) + if err != nil { + return nil, err + } + + // 检查缓存(包含格式) + cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size) + var cached RenderResult + if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" { + return &cached, nil + } + + // 获取纹理信息 + texture, err := s.textureRepo.FindByID(ctx, textureID) + if err != nil { + return nil, fmt.Errorf("获取纹理失败: %w", err) + } + if texture == nil { + return nil, errors.New("纹理不存在") + } + + // 从对象存储获取纹理文件 + textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL) + if err != nil { + return nil, fmt.Errorf("下载纹理失败: %w", err) + } + + // 渲染纹理 + renderedImage, _, err := s.RenderTextureFromData(ctx, textureData, renderType, size, format, texture.IsSlim) + if err != nil { + return nil, fmt.Errorf("渲染纹理失败: %w", err) + } + + // 保存渲染结果到对象存储 + result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, renderType, size, format, renderedImage, contentType) + if err != nil { + return nil, fmt.Errorf("保存渲染结果失败: %w", err) + } + + // 若源对象有元信息,透传 LastModified/ETag 作为参考 + if srcInfo != nil { + if result.LastModified.IsZero() { + result.LastModified = srcInfo.LastModified + } + if result.ETag == "" { + result.ETag = srcInfo.ETag + } + } + + // 缓存结果(1小时) + if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil { + s.logger.Warn("缓存渲染结果失败", zap.Error(err)) + } + + return result, nil +} + +// RenderAvatar 渲染头像(支持2D/3D模式) +func (s *textureRenderService) RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error) { + if size <= 0 || size > 1024 { + return nil, errors.New("头像渲染尺寸必须在1到1024之间") + } + contentType, err := normalizeFormat(format) + if err != nil { + return nil, err + } + + renderKey := fmt.Sprintf("avatar-%s", mode) + cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderKey, format), size) + var cached RenderResult + if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" { + return &cached, nil + } + + texture, err := s.textureRepo.FindByID(ctx, textureID) + if err != nil { + return nil, fmt.Errorf("获取纹理失败: %w", err) + } + if texture == nil { + return nil, errors.New("纹理不存在") + } + if texture.Type != model.TextureTypeSkin { + return nil, errors.New("仅皮肤纹理支持头像渲染") + } + + textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL) + if err != nil { + return nil, fmt.Errorf("下载纹理失败: %w", err) + } + + img, err := png.Decode(bytes.NewReader(textureData)) + if err != nil { + return nil, fmt.Errorf("解码PNG失败: %w", err) + } + + var rendered image.Image + switch mode { + case AvatarMode3D: + rendered = s.renderIsometricView(img, texture.IsSlim, size) + default: + rendered = s.renderHeadView(img, size) + } + + encoded, err := encodeImage(rendered, format) + if err != nil { + return nil, fmt.Errorf("编码渲染头像失败: %w", err) + } + + result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType(renderKey), size, format, encoded, contentType) + if err != nil { + return nil, fmt.Errorf("保存头像渲染失败: %w", err) + } + if srcInfo != nil && result.LastModified.IsZero() { + result.LastModified = srcInfo.LastModified + } + if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil { + s.logger.Warn("缓存头像渲染失败", zap.Error(err)) + } + + return result, nil +} + +// RenderCape 渲染披风 +func (s *textureRenderService) RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) { + if size <= 0 || size > 2048 { + return nil, errors.New("披风渲染尺寸必须在1到2048之间") + } + contentType, err := normalizeFormat(format) + if err != nil { + return nil, err + } + + cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("cape:%s", format), size) + var cached RenderResult + if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" { + return &cached, nil + } + + texture, err := s.textureRepo.FindByID(ctx, textureID) + if err != nil { + return nil, fmt.Errorf("获取纹理失败: %w", err) + } + if texture == nil { + return nil, errors.New("纹理不存在") + } + if texture.Type != model.TextureTypeCape { + return nil, errors.New("仅披风纹理支持披风渲染") + } + + textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL) + if err != nil { + return nil, fmt.Errorf("下载纹理失败: %w", err) + } + + img, err := png.Decode(bytes.NewReader(textureData)) + if err != nil { + return nil, fmt.Errorf("解码PNG失败: %w", err) + } + + rendered := s.renderCapeView(img, size) + + encoded, err := encodeImage(rendered, format) + if err != nil { + return nil, fmt.Errorf("编码披风渲染失败: %w", err) + } + + result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType("cape"), size, format, encoded, contentType) + if err != nil { + return nil, fmt.Errorf("保存披风渲染失败: %w", err) + } + if srcInfo != nil && result.LastModified.IsZero() { + result.LastModified = srcInfo.LastModified + } + + if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil { + s.logger.Warn("缓存披风渲染失败", zap.Error(err)) + } + + return result, nil +} + +// RenderPreview 渲染预览图(类似 Blessing Skin preview) +func (s *textureRenderService) RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) { + if size <= 0 || size > 2048 { + return nil, errors.New("预览渲染尺寸必须在1到2048之间") + } + + texture, err := s.textureRepo.FindByID(ctx, textureID) + if err != nil { + return nil, fmt.Errorf("获取纹理失败: %w", err) + } + if texture == nil { + return nil, errors.New("纹理不存在") + } + + switch texture.Type { + case model.TextureTypeCape: + return s.RenderCape(ctx, textureID, size, format) + default: + // 使用改进的等距视图作为默认预览 + return s.RenderTexture(ctx, textureID, RenderTypeIsometric, size, format) + } +} + +// RenderTextureFromData 从原始数据渲染纹理 +func (s *textureRenderService) RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error) { + // 解码PNG图像 + img, err := png.Decode(bytes.NewReader(textureData)) + if err != nil { + return nil, "", fmt.Errorf("解码PNG失败: %w", err) + } + + contentType, err := normalizeFormat(format) + if err != nil { + return nil, "", err + } + + // 根据渲染类型处理图像 + var renderedImage image.Image + switch renderType { + case RenderTypeFront: + renderedImage = s.renderFrontView(img, isSlim, size) + case RenderTypeBack: + renderedImage = s.renderBackView(img, isSlim, size) + case RenderTypeFull: + renderedImage = s.renderFullView(img, isSlim, size) + case RenderTypeHead: + renderedImage = s.renderHeadView(img, size) + case RenderTypeIsometric: + renderedImage = s.renderIsometricView(img, isSlim, size) + default: + return nil, "", errors.New("不支持的渲染类型") + } + + encoded, err := encodeImage(renderedImage, format) + if err != nil { + return nil, "", fmt.Errorf("编码纹理失败: %w", err) + } + + return encoded, contentType, nil +} + +// GetRenderURL 获取渲染图的URL +func (s *textureRenderService) GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string { + // 构建渲染图的存储路径 + // 格式: renders/{textureID}/{renderType}/{size}.{ext} + ext := string(format) + if ext == "" { + ext = string(ImageFormatPNG) + } + return fmt.Sprintf("renders/%d/%s/%d.%s", textureID, renderType, size, ext) +} + +// DeleteRenderCache 删除渲染缓存 +func (s *textureRenderService) DeleteRenderCache(ctx context.Context, textureID int64) error { + // 删除所有渲染类型与格式的缓存 + renderTypes := []RenderType{ + RenderTypeFront, RenderTypeBack, RenderTypeFull, RenderTypeHead, + RenderTypeIsometric, RenderType("avatar-2d"), RenderType("avatar-3d"), RenderType("cape"), + } + formats := []ImageFormat{ImageFormatPNG, ImageFormatWEBP} + sizes := []int{64, 128, 256, 512} + + for _, renderType := range renderTypes { + for _, size := range sizes { + for _, format := range formats { + cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size) + if err := s.cache.Delete(ctx, cacheKey); err != nil { + s.logger.Warn("删除渲染缓存失败", zap.Error(err)) + } + } + } + } + + return nil +} + +// downloadTexture 从对象存储下载纹理 +func (s *textureRenderService) downloadTexture(ctx context.Context, textureURL string) ([]byte, *storage.ObjectInfo, error) { + // 先直接通过 HTTP GET 下载(对公有/匿名可读对象最兼容) + if resp, httpErr := http.Get(textureURL); httpErr == nil && resp != nil && resp.StatusCode == http.StatusOK { + defer resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + if readErr == nil { + var lm time.Time + if t, parseErr := http.ParseTime(resp.Header.Get("Last-Modified")); parseErr == nil { + lm = t + } + return body, &storage.ObjectInfo{ + Size: resp.ContentLength, + LastModified: lm, + ContentType: resp.Header.Get("Content-Type"), + ETag: resp.Header.Get("ETag"), + }, nil + } + } + + // 若 HTTP 失败,再尝试通过对象存储 SDK 访问 + bucket, objectName, err := s.storage.ParseFileURL(textureURL) + if err != nil { + return nil, nil, fmt.Errorf("解析纹理URL失败: %w", err) + } + + reader, info, err := s.storage.GetObject(ctx, bucket, objectName) + if err != nil { + s.logger.Error("获取纹理对象失败", + zap.String("texture_url", textureURL), + zap.String("bucket", bucket), + zap.String("object", objectName), + zap.Error(err), + ) + return nil, nil, fmt.Errorf("获取纹理对象失败: bucket=%s object=%s err=%v", bucket, objectName, err) + } + defer reader.Close() + + data, readErr := io.ReadAll(reader) + if readErr != nil { + return nil, nil, readErr + } + return data, info, nil +} + +// saveRenderToStorage 保存渲染结果到对象存储 +func (s *textureRenderService) saveRenderToStorage(ctx context.Context, textureID int64, textureHash string, renderType RenderType, size int, format ImageFormat, imageData []byte, contentType string) (*RenderResult, error) { + // 获取存储桶 + bucketName, err := s.storage.GetBucket("renders") + if err != nil { + // 如果renders桶不存在,使用textures桶 + bucketName, err = s.storage.GetBucket("textures") + if err != nil { + return nil, fmt.Errorf("获取存储桶失败: %w", err) + } + } + + if len(textureHash) < 4 { + return nil, errors.New("纹理哈希长度不足,无法生成路径") + } + + ext := string(format) + objectName := fmt.Sprintf("renders/%s/%s/%s_%s_%d.%s", + textureHash[:2], textureHash[2:4], textureHash, renderType, size, ext) + + // 上传到对象存储 + reader := bytes.NewReader(imageData) + if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(len(imageData)), contentType); err != nil { + return nil, fmt.Errorf("上传渲染结果失败: %w", err) + } + + etag := sha256.Sum256(imageData) + result := &RenderResult{ + URL: s.storage.BuildFileURL(bucketName, objectName), + ContentType: contentType, + ETag: hex.EncodeToString(etag[:]), + LastModified: time.Now().UTC(), + Size: int64(len(imageData)), + } + + return result, nil +} + +// renderFrontView 渲染正面视图(分块+第二层,含 Alex/Steve) +func (s *textureRenderService) renderFrontView(img image.Image, isSlim bool, size int) image.Image { + base := composeFrontModel(img, isSlim) + return scaleNearest(base, size, size) +} + +// renderBackView 渲染背面视图(分块+第二层) +func (s *textureRenderService) renderBackView(img image.Image, isSlim bool, size int) image.Image { + base := composeBackModel(img, isSlim) + return scaleNearest(base, size, size) +} + +// renderFullView 渲染全身视图(正面+背面) +func (s *textureRenderService) renderFullView(img image.Image, isSlim bool, size int) image.Image { + front := composeFrontModel(img, isSlim) + back := composeBackModel(img, isSlim) + + full := image.NewRGBA(image.Rect(0, 0, front.Bounds().Dx()+back.Bounds().Dx(), front.Bounds().Dy())) + draw.Draw(full, image.Rect(0, 0, front.Bounds().Dx(), front.Bounds().Dy()), front, image.Point{}, draw.Src) + draw.Draw(full, image.Rect(front.Bounds().Dx(), 0, full.Bounds().Dx(), full.Bounds().Dy()), back, image.Point{}, draw.Src) + + return scaleNearest(full, size*2, size) +} + +// renderHeadView 渲染头像视图(包含第二层帽子) +func (s *textureRenderService) renderHeadView(img image.Image, size int) image.Image { + headBase := safeCrop(img, image.Rect(8, 8, 16, 16)) + headOverlay := safeCrop(img, image.Rect(40, 8, 48, 16)) + + if headBase == nil { + // 返回空白头像 + return scaleNearest(image.NewRGBA(image.Rect(0, 0, 8, 8)), size, size) + } + + canvas := image.NewRGBA(image.Rect(0, 0, headBase.Bounds().Dx(), headBase.Bounds().Dy())) + draw.Draw(canvas, canvas.Bounds(), headBase, headBase.Bounds().Min, draw.Src) + if headOverlay != nil { + draw.Draw(canvas, canvas.Bounds(), headOverlay, headOverlay.Bounds().Min, draw.Over) + } + + return scaleNearest(canvas, size, size) +} + +// renderIsometricView 渲染等距视图(改进 3D) +func (s *textureRenderService) renderIsometricView(img image.Image, isSlim bool, size int) image.Image { + result := image.NewRGBA(image.Rect(0, 0, size, size)) + + bgColor := color.RGBA{240, 240, 240, 255} + draw.Draw(result, result.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) + + front := scaleNearest(composeFrontModel(img, isSlim), size/2, size/2) + + for y := 0; y < front.Bounds().Dy(); y++ { + for x := 0; x < front.Bounds().Dx(); x++ { + destX := x + size/4 + destY := y + size/4 + depth := float64(x) / float64(front.Bounds().Dx()) + brightness := 1.0 - depth*0.25 + + c := front.At(x, y) + r, g, b, a := c.RGBA() + newR := uint32(float64(r) * brightness) + newG := uint32(float64(g) * brightness) + newB := uint32(float64(b) * brightness) + + if a > 0 { + result.Set(destX, destY, color.RGBA64{ + R: uint16(newR), + G: uint16(newG), + B: uint16(newB), + A: uint16(a), + }) + } + } + } + + borderColor := color.RGBA{200, 200, 200, 255} + for i := 0; i < 2; i++ { + drawLine(result, size/4, size/4, size*3/4, size/4, borderColor) + drawLine(result, size/4, size*3/4, size*3/4, size*3/4, borderColor) + drawLine(result, size/4, size/4, size/4, size*3/4, borderColor) + drawLine(result, size*3/4, size/4, size*3/4, size*3/4, borderColor) + } + + return result +} + +// drawLine 绘制直线 +func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) { + dx := abs(x2 - x1) + dy := abs(y2 - y1) + sx := -1 + if x1 < x2 { + sx = 1 + } + sy := -1 + if y1 < y2 { + sy = 1 + } + err := dx - dy + + for { + img.Set(x1, y1, c) + if x1 == x2 && y1 == y2 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x1 += sx + } + if e2 < dx { + err += dx + y1 += sy + } + } +} + +// abs 绝对值 +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// renderCapeView 渲染披风(等比缩放) +func (s *textureRenderService) renderCapeView(img image.Image, size int) image.Image { + srcBounds := img.Bounds() + if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 { + return img + } + targetWidth := size * 2 + targetHeight := size + return scaleNearest(img, targetWidth, targetHeight) +} + +// composeFrontModel 组合正面分块(含第二层) +func composeFrontModel(img image.Image, isSlim bool) *image.RGBA { + canvas := image.NewRGBA(image.Rect(0, 0, 16, 32)) + armW := 4 + if isSlim { + armW = 3 + } + + drawLayeredPart(canvas, image.Rect(4, 0, 12, 8), + safeCrop(img, image.Rect(8, 8, 16, 16)), + safeCrop(img, image.Rect(40, 8, 48, 16))) + + drawLayeredPart(canvas, image.Rect(4, 8, 12, 20), + safeCrop(img, image.Rect(20, 20, 28, 32)), + safeCrop(img, image.Rect(20, 36, 28, 48))) + + drawLayeredPart(canvas, image.Rect(0, 8, armW, 20), + safeCrop(img, image.Rect(44, 20, 48, 32)), + safeCrop(img, image.Rect(44, 36, 48, 48))) + drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20), + safeCrop(img, image.Rect(36, 52, 40, 64)), + safeCrop(img, image.Rect(52, 52, 56, 64))) + + drawLayeredPart(canvas, image.Rect(4, 20, 8, 32), + safeCrop(img, image.Rect(4, 20, 8, 32)), + safeCrop(img, image.Rect(4, 36, 8, 48))) + drawLayeredPart(canvas, image.Rect(8, 20, 12, 32), + safeCrop(img, image.Rect(20, 52, 24, 64)), + safeCrop(img, image.Rect(4, 52, 8, 64))) + + return canvas +} + +// composeBackModel 组合背面分块(含第二层) +func composeBackModel(img image.Image, isSlim bool) *image.RGBA { + canvas := image.NewRGBA(image.Rect(0, 0, 16, 32)) + armW := 4 + if isSlim { + armW = 3 + } + + drawLayeredPart(canvas, image.Rect(4, 0, 12, 8), + safeCrop(img, image.Rect(24, 8, 32, 16)), + safeCrop(img, image.Rect(56, 8, 64, 16))) + + drawLayeredPart(canvas, image.Rect(4, 8, 12, 20), + safeCrop(img, image.Rect(32, 20, 40, 32)), + safeCrop(img, image.Rect(32, 36, 40, 48))) + + drawLayeredPart(canvas, image.Rect(0, 8, armW, 20), + safeCrop(img, image.Rect(52, 20, 56, 32)), + safeCrop(img, image.Rect(52, 36, 56, 48))) + drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20), + safeCrop(img, image.Rect(44, 52, 48, 64)), + safeCrop(img, image.Rect(60, 52, 64, 64))) + + drawLayeredPart(canvas, image.Rect(4, 20, 8, 32), + safeCrop(img, image.Rect(12, 20, 16, 32)), + safeCrop(img, image.Rect(12, 36, 16, 48))) + drawLayeredPart(canvas, image.Rect(8, 20, 12, 32), + safeCrop(img, image.Rect(28, 52, 32, 64)), + safeCrop(img, image.Rect(12, 52, 16, 64))) + + return canvas +} + +// drawLayeredPart 绘制单个分块(基础层+第二层) +func drawLayeredPart(dst draw.Image, dstRect image.Rectangle, base image.Image, overlay image.Image) { + if base == nil { + return + } + dstW := dstRect.Dx() + dstH := dstRect.Dy() + + for y := 0; y < dstH; y++ { + for x := 0; x < dstW; x++ { + srcX := base.Bounds().Min.X + x*base.Bounds().Dx()/dstW + srcY := base.Bounds().Min.Y + y*base.Bounds().Dy()/dstH + dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, base.At(srcX, srcY)) + } + } + + if overlay != nil { + for y := 0; y < dstH; y++ { + for x := 0; x < dstW; x++ { + srcX := overlay.Bounds().Min.X + x*overlay.Bounds().Dx()/dstW + srcY := overlay.Bounds().Min.Y + y*overlay.Bounds().Dy()/dstH + dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, overlay.At(srcX, srcY)) + } + } + } +} + +// safeCrop 安全裁剪(超界返回nil) +func safeCrop(img image.Image, rect image.Rectangle) image.Image { + b := img.Bounds() + if rect.Min.X < 0 || rect.Min.Y < 0 || rect.Max.X > b.Max.X || rect.Max.Y > b.Max.Y { + return nil + } + subImg := image.NewRGBA(rect) + draw.Draw(subImg, rect, img, rect.Min, draw.Src) + return subImg +} + +// scaleNearest 最近邻缩放 +func scaleNearest(src image.Image, targetW, targetH int) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + srcBounds := src.Bounds() + for y := 0; y < targetH; y++ { + for x := 0; x < targetW; x++ { + srcX := srcBounds.Min.X + x*srcBounds.Dx()/targetW + srcY := srcBounds.Min.Y + y*srcBounds.Dy()/targetH + dst.Set(x, y, src.At(srcX, srcY)) + } + } + return dst +} + +// normalizeFormat 校验输出格式 +func normalizeFormat(format ImageFormat) (string, error) { + if format == "" { + format = ImageFormatPNG + } + switch format { + case ImageFormatPNG: + return "image/png", nil + case ImageFormatWEBP: + return "image/webp", nil + default: + return "", fmt.Errorf("不支持的输出格式: %s", format) + } +} + +// encodeImage 将图像编码为指定格式 +func encodeImage(img image.Image, format ImageFormat) ([]byte, error) { + var buf bytes.Buffer + switch format { + case ImageFormatWEBP: + if err := webp.Encode(&buf, img, &webp.Options{Lossless: true}); err != nil { + return nil, err + } + default: + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} diff --git a/internal/service/texture_service_test.go b/internal/service/texture_service_test.go index baa96f5..7e36aff 100644 --- a/internal/service/texture_service_test.go +++ b/internal/service/texture_service_test.go @@ -494,7 +494,8 @@ func TestTextureServiceImpl_Create(t *testing.T) { _ = userRepo.Create(context.Background(), testUser) cacheManager := NewMockCacheManager() - textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) + // 在测试中传入nil作为storageClient,因为测试不涉及文件上传 + textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) tests := []struct { name string @@ -531,13 +532,13 @@ func TestTextureServiceImpl_Create(t *testing.T) { wantErr: true, }, { - name: "材质Hash已存在", + name: "材质Hash已存在,应该成功创建并复用URL", uploaderID: 1, textureName: "DuplicateTexture", textureType: "SKIN", hash: "existing-hash", - wantErr: true, - errContains: "已存在", + wantErr: false, // 业务逻辑允许相同Hash存在,只是复用URL + errContains: "", setupMocks: func() { _ = textureRepo.Create(context.Background(), &model.Texture{ ID: 100, @@ -617,7 +618,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) { _ = textureRepo.Create(context.Background(), testTexture) cacheManager := NewMockCacheManager() - textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) + textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) tests := []struct { name string @@ -675,7 +676,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) { } cacheManager := NewMockCacheManager() - textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) + textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) ctx := context.Background() @@ -714,7 +715,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) { _ = textureRepo.Create(context.Background(), texture) cacheManager := NewMockCacheManager() - textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) + textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) ctx := context.Background() @@ -764,7 +765,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) { } cacheManager := NewMockCacheManager() - textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) + textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) ctx := context.Background() @@ -807,7 +808,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) { _ = textureRepo.Create(context.Background(), testTexture) cacheManager := NewMockCacheManager() - textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) + textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) ctx := context.Background() diff --git a/pkg/auth/manager.go b/pkg/auth/manager.go index 0833c71..f6bcda0 100644 --- a/pkg/auth/manager.go +++ b/pkg/auth/manager.go @@ -38,3 +38,9 @@ func MustGetJWTService() *JWTService { } return service } + + + + + + diff --git a/pkg/config/manager.go b/pkg/config/manager.go index 3bb4104..7be767f 100644 --- a/pkg/config/manager.go +++ b/pkg/config/manager.go @@ -63,3 +63,9 @@ func MustGetRustFSConfig() *RustFSConfig { } + + + + + + diff --git a/pkg/database/postgres.go b/pkg/database/postgres.go index aada74e..377c360 100644 --- a/pkg/database/postgres.go +++ b/pkg/database/postgres.go @@ -99,3 +99,9 @@ func GetDSN(cfg config.DatabaseConfig) string { cfg.Timezone, ) } + + + + + + diff --git a/pkg/email/manager.go b/pkg/email/manager.go index 9474f31..9f5b9ec 100644 --- a/pkg/email/manager.go +++ b/pkg/email/manager.go @@ -46,3 +46,9 @@ func MustGetService() *Service { + + + + + + diff --git a/pkg/logger/manager.go b/pkg/logger/manager.go index e75474b..0399bf6 100644 --- a/pkg/logger/manager.go +++ b/pkg/logger/manager.go @@ -49,3 +49,9 @@ func MustGetLogger() *zap.Logger { + + + + + + diff --git a/pkg/redis/manager.go b/pkg/redis/manager.go index 83f675c..7d8b1cf 100644 --- a/pkg/redis/manager.go +++ b/pkg/redis/manager.go @@ -49,3 +49,9 @@ func MustGetClient() *Client { + + + + + + diff --git a/pkg/storage/manager.go b/pkg/storage/manager.go index abf4c35..c69ce4d 100644 --- a/pkg/storage/manager.go +++ b/pkg/storage/manager.go @@ -47,3 +47,9 @@ func MustGetClient() *StorageClient { + + + + + + diff --git a/pkg/storage/minio.go b/pkg/storage/minio.go index 671438f..909446a 100644 --- a/pkg/storage/minio.go +++ b/pkg/storage/minio.go @@ -173,18 +173,34 @@ func (s *StorageClient) GetObject(ctx context.Context, bucketName, objectName st } // ParseFileURL 从文件URL中解析出bucket和objectName -// URL格式: {publicURL}/{bucket}/{objectName} +// URL格式: {publicURL}/{bucket}/{objectName}[?query],自动忽略查询参数 func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) { - // 移除 publicURL 前缀 - if !strings.HasPrefix(fileURL, s.publicURL) { + u, err := url.Parse(fileURL) + if err != nil { + return "", "", fmt.Errorf("URL解析失败: %w", err) + } + + // 校验前缀(协议+主机+端口) + public, err := url.Parse(s.publicURL) + if err != nil { + return "", "", fmt.Errorf("publicURL解析失败: %w", err) + } + if u.Scheme != public.Scheme || u.Host != public.Host { return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL) } - // 移除 publicURL 前缀和开头的 / - path := strings.TrimPrefix(fileURL, s.publicURL) - path = strings.TrimPrefix(path, "/") + // 去掉前缀与开头的斜杠,仅使用路径部分,不包含 query + path := strings.TrimPrefix(u.Path, "/") - // 解析路径 + // 如果 publicURL 自带路径前缀,移除该前缀 + pubPath := strings.TrimPrefix(public.Path, "/") + if pubPath != "" { + if !strings.HasPrefix(path, pubPath) { + return "", "", fmt.Errorf("URL格式不正确,缺少前缀 %s", public.Path) + } + path = strings.TrimPrefix(path, pubPath) + path = strings.TrimPrefix(path, "/") + } parts := strings.SplitN(path, "/", 2) if len(parts) < 2 { return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName") @@ -194,8 +210,7 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, objectName = parts[1] // URL解码 objectName - decoded, err := url.PathUnescape(objectName) - if err == nil { + if decoded, decErr := url.PathUnescape(objectName); decErr == nil { objectName = decoded }