feat: Add texture rendering endpoints and service methods
- Introduced new API endpoints for rendering textures, avatars, capes, and previews, enhancing the texture handling capabilities. - Implemented corresponding service methods in the TextureHandler to process rendering requests and return appropriate responses. - Updated the TextureRenderService interface to include methods for rendering textures, avatars, and capes, along with their respective parameters. - Enhanced error handling for invalid texture IDs and added support for different rendering types and formats. - Updated go.mod to include the webp library for image processing.
This commit is contained in:
13
go.mod
13
go.mod
@@ -5,6 +5,7 @@ go 1.24.0
|
|||||||
toolchain go1.24.2
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/chai2010/webp v1.4.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -27,17 +28,17 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.3 // 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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // 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/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/tinylib/msgp v1.3.0 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
golang.org/x/image v0.33.0 // indirect
|
golang.org/x/image v0.33.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
|
|||||||
16
go.sum
16
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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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=
|
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-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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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=
|
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/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 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.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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
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=
|
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/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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
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 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.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 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
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=
|
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/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 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
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/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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
|
|||||||
// 公开路由(无需认证)
|
// 公开路由(无需认证)
|
||||||
textureGroup.GET("", h.Search)
|
textureGroup.GET("", h.Search)
|
||||||
textureGroup.GET("/:id", h.Get)
|
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("")
|
textureAuth := textureGroup.Group("")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/service"
|
||||||
"carrotskin/internal/types"
|
"carrotskin/internal/types"
|
||||||
"strconv"
|
"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 更新材质
|
// Update 更新材质
|
||||||
func (h *TextureHandler) Update(c *gin.Context) {
|
func (h *TextureHandler) Update(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
|
|||||||
@@ -23,3 +23,9 @@ type BaseModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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
|
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,69 @@ type SecurityService interface {
|
|||||||
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
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 服务集合
|
// Services 服务集合
|
||||||
type Services struct {
|
type Services struct {
|
||||||
User UserService
|
User UserService
|
||||||
|
|||||||
@@ -315,6 +315,18 @@ func (m *MockTextureRepository) FindByHash(ctx context.Context, hash string) (*m
|
|||||||
return nil, nil
|
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) {
|
func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||||
if m.FailFind {
|
if m.FailFind {
|
||||||
return nil, 0, errors.New("mock find error")
|
return nil, 0, errors.New("mock find error")
|
||||||
|
|||||||
701
internal/service/texture_render_service.go
Normal file
701
internal/service/texture_render_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -494,7 +494,8 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), testUser)
|
_ = userRepo.Create(context.Background(), testUser)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
// 在测试中传入nil作为storageClient,因为测试不涉及文件上传
|
||||||
|
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -531,13 +532,13 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
|||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "材质Hash已存在",
|
name: "材质Hash已存在,应该成功创建并复用URL",
|
||||||
uploaderID: 1,
|
uploaderID: 1,
|
||||||
textureName: "DuplicateTexture",
|
textureName: "DuplicateTexture",
|
||||||
textureType: "SKIN",
|
textureType: "SKIN",
|
||||||
hash: "existing-hash",
|
hash: "existing-hash",
|
||||||
wantErr: true,
|
wantErr: false, // 业务逻辑允许相同Hash存在,只是复用URL
|
||||||
errContains: "已存在",
|
errContains: "",
|
||||||
setupMocks: func() {
|
setupMocks: func() {
|
||||||
_ = textureRepo.Create(context.Background(), &model.Texture{
|
_ = textureRepo.Create(context.Background(), &model.Texture{
|
||||||
ID: 100,
|
ID: 100,
|
||||||
@@ -617,7 +618,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
|
|||||||
_ = textureRepo.Create(context.Background(), testTexture)
|
_ = textureRepo.Create(context.Background(), testTexture)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -675,7 +676,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -714,7 +715,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
|
|||||||
_ = textureRepo.Create(context.Background(), texture)
|
_ = textureRepo.Create(context.Background(), texture)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -764,7 +765,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -807,7 +808,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
|
|||||||
_ = textureRepo.Create(context.Background(), testTexture)
|
_ = textureRepo.Create(context.Background(), testTexture)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ func MustGetJWTService() *JWTService {
|
|||||||
}
|
}
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,9 @@ func MustGetRustFSConfig() *RustFSConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,3 +99,9 @@ func GetDSN(cfg config.DatabaseConfig) string {
|
|||||||
cfg.Timezone,
|
cfg.Timezone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ func MustGetService() *Service {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,3 +49,9 @@ func MustGetLogger() *zap.Logger {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,3 +49,9 @@ func MustGetClient() *Client {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ func MustGetClient() *StorageClient {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -173,18 +173,34 @@ func (s *StorageClient) GetObject(ctx context.Context, bucketName, objectName st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseFileURL 从文件URL中解析出bucket和objectName
|
// ParseFileURL 从文件URL中解析出bucket和objectName
|
||||||
// URL格式: {publicURL}/{bucket}/{objectName}
|
// URL格式: {publicURL}/{bucket}/{objectName}[?query],自动忽略查询参数
|
||||||
func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) {
|
func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) {
|
||||||
// 移除 publicURL 前缀
|
u, err := url.Parse(fileURL)
|
||||||
if !strings.HasPrefix(fileURL, s.publicURL) {
|
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)
|
return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除 publicURL 前缀和开头的 /
|
// 去掉前缀与开头的斜杠,仅使用路径部分,不包含 query
|
||||||
path := strings.TrimPrefix(fileURL, s.publicURL)
|
path := strings.TrimPrefix(u.Path, "/")
|
||||||
path = strings.TrimPrefix(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)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName")
|
return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName")
|
||||||
@@ -194,8 +210,7 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string,
|
|||||||
objectName = parts[1]
|
objectName = parts[1]
|
||||||
|
|
||||||
// URL解码 objectName
|
// URL解码 objectName
|
||||||
decoded, err := url.PathUnescape(objectName)
|
if decoded, decErr := url.PathUnescape(objectName); decErr == nil {
|
||||||
if err == nil {
|
|
||||||
objectName = decoded
|
objectName = decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user