From 9b0a60033ecf773d77266e2ec3c3d51623842483 Mon Sep 17 00:00:00 2001 From: Wu XiangYu Date: Mon, 8 Dec 2025 19:12:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=9D=90=E8=B4=A8=E6=B8=B2=E6=9F=93=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?system=5Fconfig=E8=A1=A8=EF=BC=8C=E8=BD=AC=E4=B8=BA=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=88=9D?= =?UTF-8?q?=E6=AD=A5=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E5=91=98=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.docker.example | 14 + .env.example | 20 + cmd/server/main.go | 7 + configs/casbin/rbac_model.conf | 2 +- docker-compose.yml | 11 + go.mod | 18 +- go.sum | 114 ++ internal/container/container.go | 34 +- internal/handler/admin_handler.go | 366 ++++++ internal/handler/routes.go | 59 +- internal/handler/texture_handler.go | 93 -- internal/middleware/auth.go | 6 +- internal/middleware/casbin.go | 109 ++ internal/model/system_config.go | 41 - internal/repository/interfaces.go | 9 - .../repository/system_config_repository.go | 44 - .../system_config_repository_test.go | 146 --- internal/service/interfaces.go | 63 - internal/service/mocks_test.go | 47 - .../service/skin_renderer/cape_renderer.go | 121 -- internal/service/skin_renderer/minecraft.go | 113 -- internal/service/skin_renderer/point.go | 95 -- internal/service/skin_renderer/polygon.go | 200 --- internal/service/skin_renderer/polygons.go | 1080 ----------------- internal/service/skin_renderer/renderer.go | 591 --------- .../service/skin_renderer/renderer_test.go | 203 ---- internal/service/texture_render_service.go | 808 ------------ internal/service/user_service.go | 31 +- internal/service/user_service_test.go | 38 +- internal/types/common.go | 9 - pkg/auth/casbin.go | 124 ++ pkg/config/config.go | 33 + pkg/database/cache.go | 5 - pkg/database/manager.go | 3 - pkg/database/seed.go | 78 +- scripts/check-env.sh | 84 -- scripts/generate_test_account.py | 229 +++- 37 files changed, 1135 insertions(+), 3913 deletions(-) create mode 100644 internal/handler/admin_handler.go create mode 100644 internal/middleware/casbin.go delete mode 100644 internal/model/system_config.go delete mode 100644 internal/repository/system_config_repository.go delete mode 100644 internal/repository/system_config_repository_test.go delete mode 100644 internal/service/skin_renderer/cape_renderer.go delete mode 100644 internal/service/skin_renderer/minecraft.go delete mode 100644 internal/service/skin_renderer/point.go delete mode 100644 internal/service/skin_renderer/polygon.go delete mode 100644 internal/service/skin_renderer/polygons.go delete mode 100644 internal/service/skin_renderer/renderer.go delete mode 100644 internal/service/skin_renderer/renderer_test.go delete mode 100644 internal/service/texture_render_service.go create mode 100644 pkg/auth/casbin.go delete mode 100644 scripts/check-env.sh diff --git a/.env.docker.example b/.env.docker.example index ee3afcc..bbca6b7 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -2,6 +2,20 @@ # 复制此文件为 .env 后修改配置值 # 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致 +# ==================== 站点配置 ==================== +SITE_NAME=CarrotSkin +SITE_DESCRIPTION=一个优秀的Minecraft皮肤站 +REGISTRATION_ENABLED=true +DEFAULT_AVATAR= + +# ==================== 用户限制配置 ==================== +MAX_TEXTURES_PER_USER=50 +MAX_PROFILES_PER_USER=5 + +# ==================== 积分配置 ==================== +CHECKIN_REWARD=10 +TEXTURE_DOWNLOAD_REWARD=1 + # ==================== 服务配置 ==================== # 应用对外端口 APP_PORT=8080 diff --git a/.env.example b/.env.example index 99b68ca..8a6d274 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,26 @@ # CarrotSkin 环境配置文件示例 # 复制此文件为 .env 并修改相应的配置值 +# ============================================================================= +# 站点配置 +# ============================================================================= +SITE_NAME=CarrotSkin +SITE_DESCRIPTION=一个优秀的Minecraft皮肤站 +REGISTRATION_ENABLED=true +DEFAULT_AVATAR= + +# ============================================================================= +# 用户限制配置 +# ============================================================================= +MAX_TEXTURES_PER_USER=50 +MAX_PROFILES_PER_USER=5 + +# ============================================================================= +# 积分配置 +# ============================================================================= +CHECKIN_REWARD=10 +TEXTURE_DOWNLOAD_REWARD=1 + # ============================================================================= # 服务器配置 # ============================================================================= diff --git a/cmd/server/main.go b/cmd/server/main.go index 2503cca..4641090 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -91,12 +91,19 @@ func main() { } emailServiceInstance := email.MustGetService() + // 初始化Casbin权限服务 + casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance) + if err != nil { + loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err)) + } + // 创建依赖注入容器 c := container.NewContainer( database.MustGetDB(), redis.MustGetClient(), loggerInstance, auth.MustGetJWTService(), + casbinService, storageClient, emailServiceInstance, ) diff --git a/configs/casbin/rbac_model.conf b/configs/casbin/rbac_model.conf index 9ca4b92..f00f01c 100644 --- a/configs/casbin/rbac_model.conf +++ b/configs/casbin/rbac_model.conf @@ -11,4 +11,4 @@ g = _, _ e = some(where (p.eft == allow)) [matchers] -m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act +m = g(r.sub, p.sub) && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act) diff --git a/docker-compose.yml b/docker-compose.yml index 55eee7b..13ef3a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,17 @@ services: ports: - "${APP_PORT:-8080}:8080" environment: + # 站点配置 + - SITE_NAME=${SITE_NAME:-CarrotSkin} + - SITE_DESCRIPTION=${SITE_DESCRIPTION:-一个优秀的Minecraft皮肤站} + - REGISTRATION_ENABLED=${REGISTRATION_ENABLED:-true} + - DEFAULT_AVATAR=${DEFAULT_AVATAR:-} + # 用户限制配置 + - MAX_TEXTURES_PER_USER=${MAX_TEXTURES_PER_USER:-50} + - MAX_PROFILES_PER_USER=${MAX_PROFILES_PER_USER:-5} + # 积分配置 + - CHECKIN_REWARD=${CHECKIN_REWARD:-10} + - TEXTURE_DOWNLOAD_REWARD=${TEXTURE_DOWNLOAD_REWARD:-1} # 服务器配置 - SERVER_PORT=:8080 - SERVER_MODE=${SERVER_MODE:-release} diff --git a/go.mod b/go.mod index 40d2389..141e913 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ 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,9 +26,15 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/casbin/casbin/v2 v2.134.0 // indirect + github.com/casbin/gorm-adapter/v3 v3.38.0 // indirect + github.com/casbin/govaluate v1.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/glebarez/go-sqlite v1.20.3 // indirect + github.com/glebarez/sqlite v1.7.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect @@ -43,19 +48,30 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-yaml v1.19.0 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.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/microsoft/go-mssqldb v1.7.2 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // 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 gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/driver/sqlserver v1.6.0 // indirect + gorm.io/plugin/dbresolver v1.6.0 // indirect + modernc.org/libc v1.22.2 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.20.3 // indirect ) require ( diff --git a/go.sum b/go.sum index 06a89ac..8fe83ed 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,14 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -12,6 +19,12 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM= +github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/gorm-adapter/v3 v3.38.0 h1:j+2YEQU0F4RmlXaVihVV82OTe268/oKI7QKeHRkbu84= +github.com/casbin/gorm-adapter/v3 v3.38.0/go.mod h1:kjXoK8MqA3E/CcqEF2l3SCkhJj1YiHVR6SF0LMvJoH4= +github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= 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.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= @@ -23,6 +36,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -37,6 +52,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= +github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= +github.com/glebarez/sqlite v1.7.0 h1:A7Xj/KN2Lvie4Z4rrgQHY8MsbebX3NyWsL3n2i82MVI= +github.com/glebarez/sqlite v1.7.0/go.mod h1:PkeevrRlF/1BhQBCnzcMWzgrIk7IOop+qS2jUYLfHhk= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= @@ -82,19 +101,30 @@ 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.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= 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-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -103,6 +133,12 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -124,12 +160,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= @@ -143,10 +181,16 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -155,6 +199,9 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1 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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M= +github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= @@ -174,6 +221,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -213,7 +262,14 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= @@ -221,45 +277,86 @@ golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -268,7 +365,13 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= @@ -281,5 +384,16 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/dbresolver v1.6.0 h1:XvKDeOtTn1EIX6s4SrKpEH82q0gXVemhYjbYZFGFVcw= +gorm.io/plugin/dbresolver v1.6.0/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0= +modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs= +modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= diff --git a/internal/container/container.go b/internal/container/container.go index bc5091f..d72e1fb 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -22,6 +22,7 @@ type Container struct { Redis *redis.Client Logger *zap.Logger JWT *auth.JWTService + Casbin *auth.CasbinService Storage *storage.StorageClient CacheManager *database.CacheManager @@ -31,20 +32,18 @@ type Container struct { TextureRepo repository.TextureRepository TokenRepo repository.TokenRepository ClientRepo repository.ClientRepository - ConfigRepo repository.SystemConfigRepository YggdrasilRepo repository.YggdrasilRepository // Service层 - UserService service.UserService - ProfileService service.ProfileService - TextureService service.TextureService - TokenService service.TokenService - YggdrasilService service.YggdrasilService - VerificationService service.VerificationService - SecurityService service.SecurityService - CaptchaService service.CaptchaService - SignatureService *service.SignatureService - TextureRenderService service.TextureRenderService + UserService service.UserService + ProfileService service.ProfileService + TextureService service.TextureService + TokenService service.TokenService + YggdrasilService service.YggdrasilService + VerificationService service.VerificationService + SecurityService service.SecurityService + CaptchaService service.CaptchaService + SignatureService *service.SignatureService } // NewContainer 创建依赖容器 @@ -53,6 +52,7 @@ func NewContainer( redisClient *redis.Client, logger *zap.Logger, jwtService *auth.JWTService, + casbinService *auth.CasbinService, storageClient *storage.StorageClient, emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖 ) *Container { @@ -68,6 +68,7 @@ func NewContainer( Redis: redisClient, Logger: logger, JWT: jwtService, + Casbin: casbinService, Storage: storageClient, CacheManager: cacheManager, } @@ -78,7 +79,6 @@ func NewContainer( c.TextureRepo = repository.NewTextureRepository(db) c.TokenRepo = repository.NewTokenRepository(db) c.ClientRepo = repository.NewClientRepository(db) - c.ConfigRepo = repository.NewSystemConfigRepository(db) c.YggdrasilRepo = repository.NewYggdrasilRepository(db) // 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用) @@ -86,10 +86,9 @@ func NewContainer( c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger) // 初始化Service(注入缓存管理器) - c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger) + c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger) c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger) c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger) - c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger) // 获取Yggdrasil私钥并创建JWT服务(TokenService需要) // 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT @@ -193,13 +192,6 @@ func WithTokenRepo(repo repository.TokenRepository) Option { } } -// WithConfigRepo 设置系统配置仓储 -func WithConfigRepo(repo repository.SystemConfigRepository) Option { - return func(c *Container) { - c.ConfigRepo = repo - } -} - // WithUserService 设置用户服务 func WithUserService(svc service.UserService) Option { return func(c *Container) { diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go new file mode 100644 index 0000000..e8f8186 --- /dev/null +++ b/internal/handler/admin_handler.go @@ -0,0 +1,366 @@ +package handler + +import ( + "net/http" + "strconv" + + "carrotskin/internal/container" + "carrotskin/internal/model" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AdminHandler 管理员处理器 +type AdminHandler struct { + container *container.Container +} + +// NewAdminHandler 创建管理员处理器 +func NewAdminHandler(c *container.Container) *AdminHandler { + return &AdminHandler{container: c} +} + +// SetUserRoleRequest 设置用户角色请求 +type SetUserRoleRequest struct { + UserID int64 `json:"user_id" binding:"required"` + Role string `json:"role" binding:"required,oneof=user admin"` +} + +// SetUserRole 设置用户角色 +// @Summary 设置用户角色 +// @Description 管理员设置指定用户的角色 +// @Tags Admin +// @Accept json +// @Produce json +// @Param request body SetUserRoleRequest true "设置角色请求" +// @Success 200 {object} model.Response +// @Failure 400 {object} model.Response +// @Failure 403 {object} model.Response +// @Security BearerAuth +// @Router /admin/users/role [put] +func (h *AdminHandler) SetUserRole(c *gin.Context) { + var req SetUserRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondBadRequest(c, "参数错误", err) + return + } + + // 获取当前操作者ID + operatorID, _ := c.Get("user_id") + + // 不能修改自己的角色 + if req.UserID == operatorID.(int64) { + c.JSON(http.StatusBadRequest, model.NewErrorResponse( + model.CodeBadRequest, + "不能修改自己的角色", + nil, + )) + return + } + + // 检查目标用户是否存在 + targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID) + if err != nil || targetUser == nil { + c.JSON(http.StatusNotFound, model.NewErrorResponse( + model.CodeNotFound, + "用户不存在", + nil, + )) + return + } + + // 更新用户角色 + err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{ + "role": req.Role, + }) + if err != nil { + RespondServerError(c, "更新用户角色失败", err) + return + } + + h.container.Logger.Info("管理员修改用户角色", + zap.Int64("operator_id", operatorID.(int64)), + zap.Int64("target_user_id", req.UserID), + zap.String("new_role", req.Role), + ) + + c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ + "message": "用户角色更新成功", + "user_id": req.UserID, + "role": req.Role, + })) +} + +// GetUserList 获取用户列表 +// @Summary 获取用户列表 +// @Description 管理员获取所有用户列表 +// @Tags Admin +// @Produce json +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Success 200 {object} model.Response +// @Failure 403 {object} model.Response +// @Security BearerAuth +// @Router /admin/users [get] +func (h *AdminHandler) GetUserList(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + // 使用数据库直接查询用户列表 + var users []model.User + var total int64 + + db := h.container.DB + db.Model(&model.User{}).Count(&total) + db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users) + + // 构建响应(隐藏敏感信息) + userList := make([]gin.H, len(users)) + for i, u := range users { + userList[i] = gin.H{ + "id": u.ID, + "username": u.Username, + "email": u.Email, + "avatar": u.Avatar, + "role": u.Role, + "status": u.Status, + "points": u.Points, + "last_login_at": u.LastLoginAt, + "created_at": u.CreatedAt, + } + } + + c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ + "users": userList, + "total": total, + "page": page, + "page_size": pageSize, + })) +} + +// GetUserDetail 获取用户详情 +// @Summary 获取用户详情 +// @Description 管理员获取指定用户的详细信息 +// @Tags Admin +// @Produce json +// @Param id path int true "用户ID" +// @Success 200 {object} model.Response +// @Failure 404 {object} model.Response +// @Security BearerAuth +// @Router /admin/users/{id} [get] +func (h *AdminHandler) GetUserDetail(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的用户ID", err) + return + } + + user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID) + if err != nil || user == nil { + c.JSON(http.StatusNotFound, model.NewErrorResponse( + model.CodeNotFound, + "用户不存在", + nil, + )) + return + } + + c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "avatar": user.Avatar, + "role": user.Role, + "status": user.Status, + "points": user.Points, + "properties": user.Properties, + "last_login_at": user.LastLoginAt, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + })) +} + +// SetUserStatusRequest 设置用户状态请求 +type SetUserStatusRequest struct { + UserID int64 `json:"user_id" binding:"required"` + Status int16 `json:"status" binding:"required,oneof=1 0 -1"` // 1:正常, 0:禁用, -1:删除 +} + +// SetUserStatus 设置用户状态 +// @Summary 设置用户状态 +// @Description 管理员设置用户状态(启用/禁用) +// @Tags Admin +// @Accept json +// @Produce json +// @Param request body SetUserStatusRequest true "设置状态请求" +// @Success 200 {object} model.Response +// @Failure 400 {object} model.Response +// @Security BearerAuth +// @Router /admin/users/status [put] +func (h *AdminHandler) SetUserStatus(c *gin.Context) { + var req SetUserStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondBadRequest(c, "参数错误", err) + return + } + + operatorID, _ := c.Get("user_id") + + // 不能修改自己的状态 + if req.UserID == operatorID.(int64) { + c.JSON(http.StatusBadRequest, model.NewErrorResponse( + model.CodeBadRequest, + "不能修改自己的状态", + nil, + )) + return + } + + // 检查目标用户是否存在 + targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID) + if err != nil || targetUser == nil { + c.JSON(http.StatusNotFound, model.NewErrorResponse( + model.CodeNotFound, + "用户不存在", + nil, + )) + return + } + + // 更新用户状态 + err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{ + "status": req.Status, + }) + if err != nil { + RespondServerError(c, "更新用户状态失败", err) + return + } + + statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status] + + h.container.Logger.Info("管理员修改用户状态", + zap.Int64("operator_id", operatorID.(int64)), + zap.Int64("target_user_id", req.UserID), + zap.Int16("new_status", req.Status), + ) + + c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ + "message": "用户状态更新成功", + "user_id": req.UserID, + "status": req.Status, + "status_text": statusText, + })) +} + +// DeleteTexture 管理员删除材质 +// @Summary 管理员删除材质 +// @Description 管理员可以删除任意材质(用于审核不当内容) +// @Tags Admin +// @Produce json +// @Param id path int true "材质ID" +// @Success 200 {object} model.Response +// @Failure 404 {object} model.Response +// @Security BearerAuth +// @Router /admin/textures/{id} [delete] +func (h *AdminHandler) DeleteTexture(c *gin.Context) { + textureID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + RespondBadRequest(c, "无效的材质ID", err) + return + } + + operatorID, _ := c.Get("user_id") + + // 检查材质是否存在 + var texture model.Texture + if err := h.container.DB.First(&texture, textureID).Error; err != nil { + c.JSON(http.StatusNotFound, model.NewErrorResponse( + model.CodeNotFound, + "材质不存在", + nil, + )) + return + } + + // 删除材质 + if err := h.container.DB.Delete(&texture).Error; err != nil { + RespondServerError(c, "删除材质失败", err) + return + } + + h.container.Logger.Info("管理员删除材质", + zap.Int64("operator_id", operatorID.(int64)), + zap.Int64("texture_id", textureID), + zap.Int64("uploader_id", texture.UploaderID), + zap.String("texture_name", texture.Name), + ) + + c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ + "message": "材质删除成功", + "texture_id": textureID, + })) +} + +// GetTextureList 管理员获取材质列表 +// @Summary 管理员获取材质列表 +// @Description 管理员获取所有材质列表(用于审核) +// @Tags Admin +// @Produce json +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(20) +// @Success 200 {object} model.Response +// @Security BearerAuth +// @Router /admin/textures [get] +func (h *AdminHandler) GetTextureList(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + var textures []model.Texture + var total int64 + + db := h.container.DB + db.Model(&model.Texture{}).Count(&total) + db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures) + + // 构建响应 + textureList := make([]gin.H, len(textures)) + for i, t := range textures { + uploaderName := "" + if t.Uploader != nil { + uploaderName = t.Uploader.Username + } + textureList[i] = gin.H{ + "id": t.ID, + "name": t.Name, + "type": t.Type, + "hash": t.Hash, + "uploader_id": t.UploaderID, + "uploader_name": uploaderName, + "is_public": t.IsPublic, + "download_count": t.DownloadCount, + "created_at": t.CreatedAt, + } + } + + c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ + "textures": textureList, + "total": total, + "page": page, + "page_size": pageSize, + })) +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 15666af..926eea9 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -5,6 +5,7 @@ import ( "carrotskin/internal/middleware" "carrotskin/internal/model" "carrotskin/pkg/auth" + "carrotskin/pkg/config" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" @@ -20,6 +21,7 @@ type Handlers struct { Captcha *CaptchaHandler Yggdrasil *YggdrasilHandler CustomSkin *CustomSkinHandler + Admin *AdminHandler } // NewHandlers 创建所有Handler实例 @@ -32,6 +34,7 @@ func NewHandlers(c *container.Container) *Handlers { Captcha: NewCaptchaHandler(c), Yggdrasil: NewYggdrasilHandler(c), CustomSkin: NewCustomSkinHandler(c), + Admin: NewAdminHandler(c), } } @@ -68,10 +71,13 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) { registerYggdrasilRoutesWithDI(v1, h.Yggdrasil) // 系统路由 - registerSystemRoutes(v1) + registerSystemRoutes(v1, c) // CustomSkinAPI 路由 registerCustomSkinRoutes(v1, h.CustomSkin) + + // 管理员路由(需要管理员权限) + registerAdminRoutes(v1, c, h.Admin) } } @@ -113,10 +119,6 @@ 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("") @@ -192,17 +194,46 @@ func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) { } // registerSystemRoutes 注册系统路由 -func registerSystemRoutes(v1 *gin.RouterGroup) { +func registerSystemRoutes(v1 *gin.RouterGroup, c *container.Container) { system := v1.Group("/system") { - system.GET("/config", func(c *gin.Context) { - // TODO: 实现从数据库读取系统配置 - c.JSON(200, model.NewSuccessResponse(gin.H{ - "site_name": "CarrotSkin", - "site_description": "A Minecraft Skin Station", - "registration_enabled": true, - "max_textures_per_user": 100, - "max_profiles_per_user": 5, + // 公开配置(无需认证) + system.GET("/config", func(ctx *gin.Context) { + cfg, _ := config.GetConfig() + ctx.JSON(200, model.NewSuccessResponse(gin.H{ + "site_name": cfg.Site.Name, + "site_description": cfg.Site.Description, + "registration_enabled": cfg.Site.RegistrationEnabled, + "max_textures_per_user": cfg.Site.MaxTexturesPerUser, + "max_profiles_per_user": cfg.Site.MaxProfilesPerUser, + })) + }) + } +} + +// registerAdminRoutes 注册管理员路由 +func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHandler) { + admin := v1.Group("/admin") + admin.Use(middleware.AuthMiddleware(c.JWT)) + admin.Use(middleware.RequireAdmin()) + { + + // 用户管理 + admin.GET("/users", h.GetUserList) + admin.GET("/users/:id", h.GetUserDetail) + admin.PUT("/users/role", h.SetUserRole) + admin.PUT("/users/status", h.SetUserStatus) + + // 材质管理(审核) + admin.GET("/textures", h.GetTextureList) + admin.DELETE("/textures/:id", h.DeleteTexture) + + // 权限管理 + admin.GET("/permissions", func(ctx *gin.Context) { + // 获取所有权限规则 + policies, _ := c.Casbin.GetEnforcer().GetPolicy() + ctx.JSON(200, model.NewSuccessResponse(gin.H{ + "policies": policies, })) }) } diff --git a/internal/handler/texture_handler.go b/internal/handler/texture_handler.go index 208636c..81859b5 100644 --- a/internal/handler/texture_handler.go +++ b/internal/handler/texture_handler.go @@ -3,7 +3,6 @@ package handler import ( "carrotskin/internal/container" "carrotskin/internal/model" - "carrotskin/internal/service" "carrotskin/internal/types" "strconv" @@ -85,98 +84,6 @@ 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/middleware/auth.go b/internal/middleware/auth.go index fd48b9d..b6ab4e9 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -6,7 +6,7 @@ import ( "strings" "carrotskin/pkg/auth" - + "github.com/gin-gonic/gin" ) @@ -51,7 +51,7 @@ func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc { // 将用户信息存储到上下文中 c.Set("user_id", claims.UserID) c.Set("username", claims.Username) - c.Set("role", claims.Role) + c.Set("user_role", claims.Role) c.Next() }) @@ -69,7 +69,7 @@ func OptionalAuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc { if err == nil { c.Set("user_id", claims.UserID) c.Set("username", claims.Username) - c.Set("role", claims.Role) + c.Set("user_role", claims.Role) } } } diff --git a/internal/middleware/casbin.go b/internal/middleware/casbin.go new file mode 100644 index 0000000..6b3d366 --- /dev/null +++ b/internal/middleware/casbin.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "net/http" + + "carrotskin/pkg/auth" + + "github.com/gin-gonic/gin" +) + +// CasbinMiddleware Casbin权限中间件 +// 需要先经过AuthMiddleware获取用户信息 +func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string) gin.HandlerFunc { + return func(c *gin.Context) { + // 从上下文获取用户角色(由AuthMiddleware设置) + role, exists := c.Get("user_role") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未授权访问", + }) + c.Abort() + return + } + + roleStr, ok := role.(string) + if !ok || roleStr == "" { + roleStr = "user" // 默认角色 + } + + // 检查权限 + if !casbinService.CheckPermission(roleStr, resource, action) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "权限不足", + }) + c.Abort() + return + } + + c.Next() + } +} + +// RequireAdmin 要求管理员权限的中间件 +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("user_role") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未授权访问", + }) + c.Abort() + return + } + + roleStr, ok := role.(string) + if !ok || roleStr != "admin" { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "需要管理员权限", + }) + c.Abort() + return + } + + c.Next() + } +} + +// RequireRole 要求指定角色的中间件 +func RequireRole(allowedRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("user_role") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未授权访问", + }) + c.Abort() + return + } + + roleStr, ok := role.(string) + if !ok { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "权限不足", + }) + c.Abort() + return + } + + // 检查是否在允许的角色列表中 + for _, allowed := range allowedRoles { + if roleStr == allowed { + c.Next() + return + } + } + + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "权限不足", + }) + c.Abort() + } +} diff --git a/internal/model/system_config.go b/internal/model/system_config.go deleted file mode 100644 index df4c229..0000000 --- a/internal/model/system_config.go +++ /dev/null @@ -1,41 +0,0 @@ -package model - -import ( - "time" -) - -// ConfigType 配置类型 -type ConfigType string - -const ( - ConfigTypeString ConfigType = "STRING" - ConfigTypeInteger ConfigType = "INTEGER" - ConfigTypeBoolean ConfigType = "BOOLEAN" - ConfigTypeJSON ConfigType = "JSON" -) - -// SystemConfig 系统配置模型 -type SystemConfig struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Key string `gorm:"column:key;type:varchar(100);not null;uniqueIndex" json:"key"` - Value string `gorm:"column:value;type:text;not null" json:"value"` - Description string `gorm:"column:description;type:varchar(255);not null;default:''" json:"description"` - Type ConfigType `gorm:"column:type;type:varchar(50);not null;default:'STRING'" json:"type"` // STRING, INTEGER, BOOLEAN, JSON - IsPublic bool `gorm:"column:is_public;not null;default:false;index" json:"is_public"` // 是否可被前端获取 - CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` -} - -// TableName 指定表名 -func (SystemConfig) TableName() string { - return "system_config" -} - -// SystemConfigPublicResponse 公开配置响应 -type SystemConfigPublicResponse struct { - SiteName string `json:"site_name"` - SiteDescription string `json:"site_description"` - RegistrationEnabled bool `json:"registration_enabled"` - MaintenanceMode bool `json:"maintenance_mode"` - Announcement string `json:"announcement"` -} diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index f630c42..a8ada35 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -78,15 +78,6 @@ type TokenRepository interface { BatchDelete(ctx context.Context, accessTokens []string) (int64, error) } -// SystemConfigRepository 系统配置仓储接口 -type SystemConfigRepository interface { - GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) - GetPublic(ctx context.Context) ([]model.SystemConfig, error) - GetAll(ctx context.Context) ([]model.SystemConfig, error) - Update(ctx context.Context, config *model.SystemConfig) error - UpdateValue(ctx context.Context, key, value string) error -} - // YggdrasilRepository Yggdrasil仓储接口 type YggdrasilRepository interface { GetPasswordByID(ctx context.Context, id int64) (string, error) diff --git a/internal/repository/system_config_repository.go b/internal/repository/system_config_repository.go deleted file mode 100644 index 41b2cc1..0000000 --- a/internal/repository/system_config_repository.go +++ /dev/null @@ -1,44 +0,0 @@ -package repository - -import ( - "carrotskin/internal/model" - "context" - - "gorm.io/gorm" -) - -// systemConfigRepository SystemConfigRepository的实现 -type systemConfigRepository struct { - db *gorm.DB -} - -// NewSystemConfigRepository 创建SystemConfigRepository实例 -func NewSystemConfigRepository(db *gorm.DB) SystemConfigRepository { - return &systemConfigRepository{db: db} -} - -func (r *systemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) { - var config model.SystemConfig - err := r.db.WithContext(ctx).Where("key = ?", key).First(&config).Error - return handleNotFoundResult(&config, err) -} - -func (r *systemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) { - var configs []model.SystemConfig - err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&configs).Error - return configs, err -} - -func (r *systemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) { - var configs []model.SystemConfig - err := r.db.WithContext(ctx).Find(&configs).Error - return configs, err -} - -func (r *systemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error { - return r.db.WithContext(ctx).Save(config).Error -} - -func (r *systemConfigRepository) UpdateValue(ctx context.Context, key, value string) error { - return r.db.WithContext(ctx).Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error -} diff --git a/internal/repository/system_config_repository_test.go b/internal/repository/system_config_repository_test.go deleted file mode 100644 index b717fcf..0000000 --- a/internal/repository/system_config_repository_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package repository - -import ( - "testing" -) - -// TestSystemConfigRepository_QueryConditions 测试系统配置查询条件逻辑 -func TestSystemConfigRepository_QueryConditions(t *testing.T) { - tests := []struct { - name string - key string - isPublic bool - wantValid bool - }{ - { - name: "有效的配置键", - key: "site_name", - isPublic: true, - wantValid: true, - }, - { - name: "配置键为空", - key: "", - isPublic: true, - wantValid: false, - }, - { - name: "公开配置查询", - key: "site_name", - isPublic: true, - wantValid: true, - }, - { - name: "私有配置查询", - key: "secret_key", - isPublic: false, - wantValid: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - isValid := tt.key != "" - if isValid != tt.wantValid { - t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid) - } - }) - } -} - -// TestSystemConfigRepository_PublicConfigLogic 测试公开配置逻辑 -func TestSystemConfigRepository_PublicConfigLogic(t *testing.T) { - tests := []struct { - name string - isPublic bool - wantInclude bool - }{ - { - name: "只获取公开配置", - isPublic: true, - wantInclude: true, - }, - { - name: "私有配置不应包含", - isPublic: false, - wantInclude: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 验证逻辑:GetPublicSystemConfigs应该只返回is_public=true的配置 - if tt.isPublic != tt.wantInclude { - t.Errorf("Public config logic failed: isPublic=%v, wantInclude=%v", tt.isPublic, tt.wantInclude) - } - }) - } -} - -// TestSystemConfigRepository_UpdateValueLogic 测试更新配置值逻辑 -func TestSystemConfigRepository_UpdateValueLogic(t *testing.T) { - tests := []struct { - name string - key string - value string - wantValid bool - }{ - { - name: "有效的键值对", - key: "site_name", - value: "CarrotSkin", - wantValid: true, - }, - { - name: "键为空", - key: "", - value: "CarrotSkin", - wantValid: false, - }, - { - name: "值为空(可能有效)", - key: "site_name", - value: "", - wantValid: true, // 空值也可能是有效的 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - isValid := tt.key != "" - if isValid != tt.wantValid { - t.Errorf("Update value validation failed: got %v, want %v", isValid, tt.wantValid) - } - }) - } -} - -// TestSystemConfigRepository_ErrorHandling 测试错误处理逻辑 -func TestSystemConfigRepository_ErrorHandling(t *testing.T) { - tests := []struct { - name string - isNotFound bool - wantNilConfig bool - }{ - { - name: "记录未找到应该返回nil配置", - isNotFound: true, - wantNilConfig: true, - }, - { - name: "找到记录应该返回配置", - isNotFound: false, - wantNilConfig: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 验证错误处理逻辑:如果是RecordNotFound,返回nil配置 - if tt.isNotFound != tt.wantNilConfig { - t.Errorf("Error handling logic failed: isNotFound=%v, wantNilConfig=%v", tt.isNotFound, tt.wantNilConfig) - } - }) - } -} - diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 444f60d..0f24e08 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -136,69 +136,6 @@ 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 1c9fc30..86e0cd6 100644 --- a/internal/service/mocks_test.go +++ b/internal/service/mocks_test.go @@ -565,53 +565,6 @@ func (m *MockTokenRepository) BatchDelete(ctx context.Context, accessTokens []st return count, nil } -// MockSystemConfigRepository 模拟SystemConfigRepository -type MockSystemConfigRepository struct { - configs map[string]*model.SystemConfig -} - -func NewMockSystemConfigRepository() *MockSystemConfigRepository { - return &MockSystemConfigRepository{ - configs: make(map[string]*model.SystemConfig), - } -} - -func (m *MockSystemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) { - if config, ok := m.configs[key]; ok { - return config, nil - } - return nil, nil -} - -func (m *MockSystemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) { - var result []model.SystemConfig - for _, v := range m.configs { - result = append(result, *v) - } - return result, nil -} - -func (m *MockSystemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) { - var result []model.SystemConfig - for _, v := range m.configs { - result = append(result, *v) - } - return result, nil -} - -func (m *MockSystemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error { - m.configs[config.Key] = config - return nil -} - -func (m *MockSystemConfigRepository) UpdateValue(ctx context.Context, key, value string) error { - if config, ok := m.configs[key]; ok { - config.Value = value - return nil - } - return errors.New("config not found") -} - // ============================================================================ // Service Mocks // ============================================================================ diff --git a/internal/service/skin_renderer/cape_renderer.go b/internal/service/skin_renderer/cape_renderer.go deleted file mode 100644 index bf81143..0000000 --- a/internal/service/skin_renderer/cape_renderer.go +++ /dev/null @@ -1,121 +0,0 @@ -package skin_renderer - -import ( - "bytes" - "image" - "image/png" -) - -// CapeRenderer 披风渲染器 -type CapeRenderer struct{} - -// NewCapeRenderer 创建披风渲染器 -func NewCapeRenderer() *CapeRenderer { - return &CapeRenderer{} -} - -// Render 渲染披风 -// 披风纹理布局: -// - 正面: (1, 1) 到 (11, 17) - 10x16 像素 -// - 背面: (12, 1) 到 (22, 17) - 10x16 像素 -func (r *CapeRenderer) Render(capeData []byte, height int) (image.Image, error) { - // 解码披风图像 - img, err := png.Decode(bytes.NewReader(capeData)) - if err != nil { - return nil, err - } - - bounds := img.Bounds() - srcWidth := bounds.Dx() - srcHeight := bounds.Dy() - - // 披风纹理可能是 64x32 或 22x17 - // 标准披风正面区域 - var frontX, frontY, frontW, frontH int - - if srcWidth >= 64 && srcHeight >= 32 { - // 64x32 格式(Minecraft 1.8+) - // 正面: (1, 1) 到 (11, 17) - frontX = 1 - frontY = 1 - frontW = 10 - frontH = 16 - } else if srcWidth >= 22 && srcHeight >= 17 { - // 22x17 格式(旧版) - frontX = 1 - frontY = 1 - frontW = 10 - frontH = 16 - } else { - // 未知格式,直接缩放整个图像 - return resizeImageBilinear(img, height*srcWidth/srcHeight, height), nil - } - - // 提取正面区域 - front := image.NewRGBA(image.Rect(0, 0, frontW, frontH)) - for y := 0; y < frontH; y++ { - for x := 0; x < frontW; x++ { - front.Set(x, y, img.At(bounds.Min.X+frontX+x, bounds.Min.Y+frontY+y)) - } - } - - // 计算输出尺寸,保持宽高比 - outputWidth := height * frontW / frontH - if outputWidth < 1 { - outputWidth = 1 - } - - // 使用最近邻缩放保持像素风格 - return scaleNearest(front, outputWidth, height), nil -} - -// scaleNearest 最近邻缩放 -func scaleNearest(src image.Image, width, height int) *image.RGBA { - bounds := src.Bounds() - srcW := bounds.Dx() - srcH := bounds.Dy() - - dst := image.NewRGBA(image.Rect(0, 0, width, height)) - - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - srcX := bounds.Min.X + x*srcW/width - srcY := bounds.Min.Y + y*srcH/height - dst.Set(x, y, src.At(srcX, srcY)) - } - } - - return dst -} - -// resizeImageBilinear 双线性插值缩放 -func resizeImageBilinear(src image.Image, width, height int) *image.RGBA { - bounds := src.Bounds() - srcW := float64(bounds.Dx()) - srcH := float64(bounds.Dy()) - - dst := image.NewRGBA(image.Rect(0, 0, width, height)) - - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - // 计算源图像中的位置 - srcX := float64(x) * srcW / float64(width) - srcY := float64(y) * srcH / float64(height) - - // 简单的最近邻(可以改进为双线性) - ix := int(srcX) - iy := int(srcY) - - if ix >= bounds.Dx() { - ix = bounds.Dx() - 1 - } - if iy >= bounds.Dy() { - iy = bounds.Dy() - 1 - } - - dst.Set(x, y, src.At(bounds.Min.X+ix, bounds.Min.Y+iy)) - } - } - - return dst -} diff --git a/internal/service/skin_renderer/minecraft.go b/internal/service/skin_renderer/minecraft.go deleted file mode 100644 index 899c15c..0000000 --- a/internal/service/skin_renderer/minecraft.go +++ /dev/null @@ -1,113 +0,0 @@ -package skin_renderer - -import ( - "image" - "image/color" - "image/draw" -) - -// Minecraft 提供 Minecraft 皮肤渲染的入口方法 -// 与 blessing/texture-renderer 的 Minecraft 类保持兼容 -type Minecraft struct{} - -// NewMinecraft 创建 Minecraft 渲染器实例 -func NewMinecraft() *Minecraft { - return &Minecraft{} -} - -// RenderSkin 渲染完整皮肤预览(正面+背面) -// ratio: 缩放比例,默认 7.0 -// isAlex: 是否为 Alex 模型(细手臂) -func (m *Minecraft) RenderSkin(skinData []byte, ratio float64, isAlex bool) (image.Image, error) { - vp := 15 // vertical padding - hp := 30 // horizontal padding - ip := 15 // internal padding - - // 渲染正面(-45度) - frontRenderer := NewSkinRenderer(ratio, false, -45, -25) - front, err := frontRenderer.Render(skinData, isAlex) - if err != nil { - return nil, err - } - - // 渲染背面(135度) - backRenderer := NewSkinRenderer(ratio, false, 135, -25) - back, err := backRenderer.Render(skinData, isAlex) - if err != nil { - return nil, err - } - - width := front.Bounds().Dx() - height := front.Bounds().Dy() - - // 创建画布 - canvas := createEmptyCanvas((hp+width+ip)*2, vp*2+height) - - // 绘制背面(左侧) - draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+height), back, back.Bounds().Min, draw.Over) - - // 绘制正面(右侧) - draw.Draw(canvas, image.Rect(hp+width+ip*2, vp, hp+width*2+ip*2, vp+height), front, front.Bounds().Min, draw.Over) - - return canvas, nil -} - -// RenderCape 渲染披风 -// height: 输出高度 -func (m *Minecraft) RenderCape(capeData []byte, height int) (image.Image, error) { - vp := 20 // vertical padding - hp := 40 // horizontal padding - - renderer := NewCapeRenderer() - cape, err := renderer.Render(capeData, height) - if err != nil { - return nil, err - } - - width := cape.Bounds().Dx() - capeHeight := cape.Bounds().Dy() - - canvas := createEmptyCanvas(hp*2+width, vp*2+capeHeight) - draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+capeHeight), cape, cape.Bounds().Min, draw.Over) - - return canvas, nil -} - -// Render2DAvatar 渲染 2D 头像 -// ratio: 缩放比例,默认 15.0 -func (m *Minecraft) Render2DAvatar(skinData []byte, ratio float64) (image.Image, error) { - renderer := NewSkinRendererFull(ratio, true, 0, 0, 0, 0, 0, 0, 0, true) - return renderer.Render(skinData, false) -} - -// Render3DAvatar 渲染 3D 头像 -// ratio: 缩放比例,默认 15.0 -func (m *Minecraft) Render3DAvatar(skinData []byte, ratio float64) (image.Image, error) { - renderer := NewSkinRenderer(ratio, true, 45, -25) - return renderer.Render(skinData, false) -} - -// RenderSkinWithAngle 渲染指定角度的皮肤 -func (m *Minecraft) RenderSkinWithAngle(skinData []byte, ratio float64, isAlex bool, hRotation, vRotation float64) (image.Image, error) { - renderer := NewSkinRenderer(ratio, false, hRotation, vRotation) - return renderer.Render(skinData, isAlex) -} - -// RenderHeadWithAngle 渲染指定角度的头像 -func (m *Minecraft) RenderHeadWithAngle(skinData []byte, ratio float64, hRotation, vRotation float64) (image.Image, error) { - renderer := NewSkinRenderer(ratio, true, hRotation, vRotation) - return renderer.Render(skinData, false) -} - -// createEmptyCanvas 创建透明画布 -func createEmptyCanvas(width, height int) *image.RGBA { - img := image.NewRGBA(image.Rect(0, 0, width, height)) - // 填充透明背景 - transparent := color.RGBA{0, 0, 0, 0} - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - img.SetRGBA(x, y, transparent) - } - } - return img -} diff --git a/internal/service/skin_renderer/point.go b/internal/service/skin_renderer/point.go deleted file mode 100644 index 794aa1c..0000000 --- a/internal/service/skin_renderer/point.go +++ /dev/null @@ -1,95 +0,0 @@ -// Package skin_renderer 实现 Minecraft 皮肤的 3D 渲染 -// 移植自 blessing/texture-renderer -package skin_renderer - -// Point 表示 3D 空间中的一个点 -type Point struct { - // 原始坐标 - originX, originY, originZ float64 - // 投影后的坐标 - destX, destY, destZ float64 - // 是否已投影 - isProjected bool - isPreProjected bool -} - -// NewPoint 创建一个新的 3D 点 -func NewPoint(x, y, z float64) *Point { - return &Point{ - originX: x, - originY: y, - originZ: z, - } -} - -// Project 将 3D 点投影到 2D 平面 -// 使用欧拉角旋转:alpha 为垂直旋转(X轴),omega 为水平旋转(Y轴) -func (p *Point) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) { - x := p.originX - y := p.originY - z := p.originZ - - // 3D 旋转投影公式 - p.destX = x*cosOmega + z*sinOmega - p.destY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega - p.destZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega - - p.isProjected = true - - // 更新边界 - if p.destX < *minX { - *minX = p.destX - } - if p.destX > *maxX { - *maxX = p.destX - } - if p.destY < *minY { - *minY = p.destY - } - if p.destY > *maxY { - *maxY = p.destY - } -} - -// PreProject 预投影,用于部件独立旋转(如头部、手臂) -// dx, dy, dz 为旋转中心点 -func (p *Point) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) { - if p.isPreProjected { - return - } - - // 相对于旋转中心的坐标 - x := p.originX - dx - y := p.originY - dy - z := p.originZ - dz - - // 旋转后加回偏移 - p.originX = x*cosOmega + z*sinOmega + dx - p.originY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega + dy - p.originZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega + dz - - p.isPreProjected = true -} - -// GetDestCoord 获取投影后的坐标 -func (p *Point) GetDestCoord() (x, y, z float64) { - return p.destX, p.destY, p.destZ -} - -// GetOriginCoord 获取原始坐标 -func (p *Point) GetOriginCoord() (x, y, z float64) { - return p.originX, p.originY, p.originZ -} - -// IsProjected 返回是否已投影 -func (p *Point) IsProjected() bool { - return p.isProjected -} - -// GetDepth 获取深度值(用于排序) -func (p *Point) GetDepth(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) float64 { - if !p.isProjected { - p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY) - } - return p.destZ -} diff --git a/internal/service/skin_renderer/polygon.go b/internal/service/skin_renderer/polygon.go deleted file mode 100644 index 85cfda7..0000000 --- a/internal/service/skin_renderer/polygon.go +++ /dev/null @@ -1,200 +0,0 @@ -package skin_renderer - -import ( - "image" - "image/color" -) - -// Polygon 表示一个四边形面片 -type Polygon struct { - dots [4]*Point - color color.RGBA - isProjected bool - face string // 面的方向: "x", "y", "z" - faceDepth float64 // 面的深度 -} - -// NewPolygon 创建一个新的多边形 -func NewPolygon(dots [4]*Point, c color.RGBA) *Polygon { - p := &Polygon{ - dots: dots, - color: c, - } - - // 确定面的方向 - x0, y0, z0 := dots[0].GetOriginCoord() - x1, y1, z1 := dots[1].GetOriginCoord() - x2, y2, z2 := dots[2].GetOriginCoord() - - if x0 == x1 && x1 == x2 { - p.face = "x" - p.faceDepth = x0 - } else if y0 == y1 && y1 == y2 { - p.face = "y" - p.faceDepth = y0 - } else if z0 == z1 && z1 == z2 { - p.face = "z" - p.faceDepth = z0 - } - - return p -} - -// Project 投影多边形的所有顶点 -func (p *Polygon) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) { - for _, dot := range p.dots { - if !dot.IsProjected() { - dot.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY) - } - } - p.isProjected = true -} - -// PreProject 预投影多边形的所有顶点 -func (p *Polygon) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) { - for _, dot := range p.dots { - dot.PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega) - } -} - -// IsProjected 返回是否已投影 -func (p *Polygon) IsProjected() bool { - return p.isProjected -} - -// AddToImage 将多边形绘制到图像上 -func (p *Polygon) AddToImage(img *image.RGBA, minX, minY, ratio float64) { - // 检查透明度,完全透明则跳过 - if p.color.A == 0 { - return - } - - // 获取投影后的 2D 坐标 - points := make([][2]float64, 4) - var coordX, coordY *float64 - - samePlanX := true - samePlanY := true - - for i, dot := range p.dots { - x, y, _ := dot.GetDestCoord() - points[i] = [2]float64{ - (x - minX) * ratio, - (y - minY) * ratio, - } - - if coordX == nil { - coordX = &x - coordY = &y - } else { - if *coordX != x { - samePlanX = false - } - if *coordY != y { - samePlanY = false - } - } - } - - // 如果所有点在同一平面(退化面),跳过 - if samePlanX || samePlanY { - return - } - - // 使用扫描线算法填充多边形 - fillPolygon(img, points, p.color) -} - -// fillPolygon 使用扫描线算法填充四边形 -func fillPolygon(img *image.RGBA, points [][2]float64, c color.RGBA) { - // 找到 Y 的范围 - minY := points[0][1] - maxY := points[0][1] - for _, pt := range points { - if pt[1] < minY { - minY = pt[1] - } - if pt[1] > maxY { - maxY = pt[1] - } - } - - bounds := img.Bounds() - - // 扫描每一行 - for y := int(minY); y <= int(maxY); y++ { - if y < bounds.Min.Y || y >= bounds.Max.Y { - continue - } - - // 找到这一行与多边形边的交点 - var intersections []float64 - n := len(points) - for i := 0; i < n; i++ { - j := (i + 1) % n - y1, y2 := points[i][1], points[j][1] - x1, x2 := points[i][0], points[j][0] - - // 检查这条边是否与当前扫描线相交 - if (y1 <= float64(y) && y2 > float64(y)) || (y2 <= float64(y) && y1 > float64(y)) { - // 计算交点的 X 坐标 - t := (float64(y) - y1) / (y2 - y1) - x := x1 + t*(x2-x1) - intersections = append(intersections, x) - } - } - - // 排序交点 - for i := 0; i < len(intersections)-1; i++ { - for j := i + 1; j < len(intersections); j++ { - if intersections[i] > intersections[j] { - intersections[i], intersections[j] = intersections[j], intersections[i] - } - } - } - - // 填充交点之间的像素 - for i := 0; i+1 < len(intersections); i += 2 { - xStart := int(intersections[i]) - xEnd := int(intersections[i+1]) - - for x := xStart; x <= xEnd; x++ { - if x >= bounds.Min.X && x < bounds.Max.X { - // Alpha 混合 - if c.A == 255 { - img.SetRGBA(x, y, c) - } else { - existing := img.RGBAAt(x, y) - blended := alphaBlend(existing, c) - img.SetRGBA(x, y, blended) - } - } - } - } - } -} - -// alphaBlend 执行 Alpha 混合 -func alphaBlend(dst, src color.RGBA) color.RGBA { - if src.A == 0 { - return dst - } - if src.A == 255 { - return src - } - - srcA := float64(src.A) / 255.0 - dstA := float64(dst.A) / 255.0 - outA := srcA + dstA*(1-srcA) - - if outA == 0 { - return color.RGBA{} - } - - return color.RGBA{ - R: uint8((float64(src.R)*srcA + float64(dst.R)*dstA*(1-srcA)) / outA), - G: uint8((float64(src.G)*srcA + float64(dst.G)*dstA*(1-srcA)) / outA), - B: uint8((float64(src.B)*srcA + float64(dst.B)*dstA*(1-srcA)) / outA), - A: uint8(outA * 255), - } -} diff --git a/internal/service/skin_renderer/polygons.go b/internal/service/skin_renderer/polygons.go deleted file mode 100644 index 9485717..0000000 --- a/internal/service/skin_renderer/polygons.go +++ /dev/null @@ -1,1080 +0,0 @@ -package skin_renderer - -// generatePolygons 生成所有部件的多边形 -func (r *SkinRenderer) generatePolygons() { - r.polygons = make(map[string]map[string][]*Polygon) - - // 初始化各部件的面 - parts := []string{"helmet", "head", "torso", "rightArm", "leftArm", "rightLeg", "leftLeg"} - faces := []string{"front", "back", "top", "bottom", "right", "left"} - - for _, part := range parts { - r.polygons[part] = make(map[string][]*Polygon) - for _, face := range faces { - r.polygons[part][face] = []*Polygon{} - } - } - - hd := r.hdRatio - - // 生成头部多边形 - r.generateHeadPolygons(hd) - - // 生成头盔多边形 - r.generateHelmetPolygons(hd) - - if !r.headOnly { - // 生成躯干多边形 - r.generateTorsoPolygons(hd) - - // 生成右臂多边形 - r.generateRightArmPolygons(hd) - - // 生成左臂多边形 - r.generateLeftArmPolygons(hd) - - // 生成右腿多边形 - r.generateRightLegPolygons(hd) - - // 生成左腿多边形 - r.generateLeftLegPolygons(hd) - } -} - -// generateHeadPolygons 生成头部多边形 -func (r *SkinRenderer) generateHeadPolygons(hd int) { - // 创建头部体积点 - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= 8*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 8*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - // 前后面的点 - volumePoints[i][j][-2*hd] = NewPoint(float64(i), float64(j), float64(-2*hd)) - volumePoints[i][j][6*hd] = NewPoint(float64(i), float64(j), float64(6*hd)) - } - } - - // 左右面的点 - for j := 0; j <= 8*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[8*hd][j] == nil { - volumePoints[8*hd][j] = make(map[int]*Point) - } - for k := -2 * hd; k <= 6*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(0, float64(j), float64(k)) - } - if volumePoints[8*hd][j][k] == nil { - volumePoints[8*hd][j][k] = NewPoint(float64(8*hd), float64(j), float64(k)) - } - } - } - - // 上下面的点 - for i := 0; i <= 8*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][8*hd] == nil { - volumePoints[i][8*hd] = make(map[int]*Point) - } - for k := -2 * hd; k <= 6*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i), 0, float64(k)) - } - if volumePoints[i][8*hd][k] == nil { - volumePoints[i][8*hd][k] = NewPoint(float64(i), float64(8*hd), float64(k)) - } - } - } - - // 生成前后面多边形 - for i := 0; i < 8*hd; i++ { - for j := 0; j < 8*hd; j++ { - // 背面 - r.polygons["head"]["back"] = append(r.polygons["head"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][-2*hd], - volumePoints[i+1][j][-2*hd], - volumePoints[i+1][j+1][-2*hd], - volumePoints[i][j+1][-2*hd], - }, - r.getPixelColor((32*hd-1)-i, 8*hd+j), - )) - // 正面 - r.polygons["head"]["front"] = append(r.polygons["head"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][6*hd], - volumePoints[i+1][j][6*hd], - volumePoints[i+1][j+1][6*hd], - volumePoints[i][j+1][6*hd], - }, - r.getPixelColor(8*hd+i, 8*hd+j), - )) - } - } - - // 生成左右面多边形 - for j := 0; j < 8*hd; j++ { - for k := -2 * hd; k < 6*hd; k++ { - // 右面 - r.polygons["head"]["right"] = append(r.polygons["head"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(k+2*hd, 8*hd+j), - )) - // 左面 - r.polygons["head"]["left"] = append(r.polygons["head"]["left"], NewPolygon( - [4]*Point{ - volumePoints[8*hd][j][k], - volumePoints[8*hd][j][k+1], - volumePoints[8*hd][j+1][k+1], - volumePoints[8*hd][j+1][k], - }, - r.getPixelColor((24*hd-1)-k-2*hd, 8*hd+j), - )) - } - } - - // 生成上下面多边形 - for i := 0; i < 8*hd; i++ { - for k := -2 * hd; k < 6*hd; k++ { - // 顶面 - r.polygons["head"]["top"] = append(r.polygons["head"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(8*hd+i, k+2*hd), - )) - // 底面 - r.polygons["head"]["bottom"] = append(r.polygons["head"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][8*hd][k], - volumePoints[i+1][8*hd][k], - volumePoints[i+1][8*hd][k+1], - volumePoints[i][8*hd][k+1], - }, - r.getPixelColor(16*hd+i, 2*hd+k), - )) - } - } -} - -// generateHelmetPolygons 生成头盔/第二层多边形 -func (r *SkinRenderer) generateHelmetPolygons(hd int) { - // 头盔比头部稍大一点 - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= 8*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 8*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - // 稍微放大 - volumePoints[i][j][-2*hd] = NewPoint(float64(i)*9/8-0.5*float64(hd), float64(j)*9/8-0.5*float64(hd), -2.5*float64(hd)) - volumePoints[i][j][6*hd] = NewPoint(float64(i)*9/8-0.5*float64(hd), float64(j)*9/8-0.5*float64(hd), 6.5*float64(hd)) - } - } - - for j := 0; j <= 8*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[8*hd][j] == nil { - volumePoints[8*hd][j] = make(map[int]*Point) - } - for k := -2 * hd; k <= 6*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(-0.5*float64(hd), float64(j)*9/8-0.5*float64(hd), float64(k)*9/8-0.5*float64(hd)) - } - if volumePoints[8*hd][j][k] == nil { - volumePoints[8*hd][j][k] = NewPoint(8.5*float64(hd), float64(j)*9/8-0.5*float64(hd), float64(k)*9/8-0.5*float64(hd)) - } - } - } - - for i := 0; i <= 8*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][8*hd] == nil { - volumePoints[i][8*hd] = make(map[int]*Point) - } - for k := -2 * hd; k <= 6*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i)*9/8-0.5*float64(hd), -0.5*float64(hd), float64(k)*9/8-0.5*float64(hd)) - } - if volumePoints[i][8*hd][k] == nil { - volumePoints[i][8*hd][k] = NewPoint(float64(i)*9/8-0.5*float64(hd), 8.5*float64(hd), float64(k)*9/8-0.5*float64(hd)) - } - } - } - - // 生成前后面多边形(头盔纹理偏移 32*hd) - for i := 0; i < 8*hd; i++ { - for j := 0; j < 8*hd; j++ { - r.polygons["helmet"]["back"] = append(r.polygons["helmet"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][-2*hd], - volumePoints[i+1][j][-2*hd], - volumePoints[i+1][j+1][-2*hd], - volumePoints[i][j+1][-2*hd], - }, - r.getPixelColor(32*hd+(32*hd-1)-i, 8*hd+j), - )) - r.polygons["helmet"]["front"] = append(r.polygons["helmet"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][6*hd], - volumePoints[i+1][j][6*hd], - volumePoints[i+1][j+1][6*hd], - volumePoints[i][j+1][6*hd], - }, - r.getPixelColor(32*hd+8*hd+i, 8*hd+j), - )) - } - } - - for j := 0; j < 8*hd; j++ { - for k := -2 * hd; k < 6*hd; k++ { - r.polygons["helmet"]["right"] = append(r.polygons["helmet"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(32*hd+k+2*hd, 8*hd+j), - )) - r.polygons["helmet"]["left"] = append(r.polygons["helmet"]["left"], NewPolygon( - [4]*Point{ - volumePoints[8*hd][j][k], - volumePoints[8*hd][j][k+1], - volumePoints[8*hd][j+1][k+1], - volumePoints[8*hd][j+1][k], - }, - r.getPixelColor(32*hd+(24*hd-1)-k-2*hd, 8*hd+j), - )) - } - } - - for i := 0; i < 8*hd; i++ { - for k := -2 * hd; k < 6*hd; k++ { - r.polygons["helmet"]["top"] = append(r.polygons["helmet"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(32*hd+8*hd+i, k+2*hd), - )) - r.polygons["helmet"]["bottom"] = append(r.polygons["helmet"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][8*hd][k], - volumePoints[i+1][8*hd][k], - volumePoints[i+1][8*hd][k+1], - volumePoints[i][8*hd][k+1], - }, - r.getPixelColor(32*hd+16*hd+i, 2*hd+k), - )) - } - } -} - -// generateTorsoPolygons 生成躯干多边形 -func (r *SkinRenderer) generateTorsoPolygons(hd int) { - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= 8*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 12*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - volumePoints[i][j][0] = NewPoint(float64(i), float64(j+8*hd), 0) - volumePoints[i][j][4*hd] = NewPoint(float64(i), float64(j+8*hd), float64(4*hd)) - } - } - - for j := 0; j <= 12*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[8*hd][j] == nil { - volumePoints[8*hd][j] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(0, float64(j+8*hd), float64(k)) - } - if volumePoints[8*hd][j][k] == nil { - volumePoints[8*hd][j][k] = NewPoint(float64(8*hd), float64(j+8*hd), float64(k)) - } - } - } - - for i := 0; i <= 8*hd; i++ { - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][12*hd] == nil { - volumePoints[i][12*hd] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i), float64(8*hd), float64(k)) - } - if volumePoints[i][12*hd][k] == nil { - volumePoints[i][12*hd][k] = NewPoint(float64(i), float64(12*hd+8*hd), float64(k)) - } - } - } - - for i := 0; i < 8*hd; i++ { - for j := 0; j < 12*hd; j++ { - r.polygons["torso"]["back"] = append(r.polygons["torso"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor((40*hd-1)-i, 20*hd+j), - )) - r.polygons["torso"]["front"] = append(r.polygons["torso"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(20*hd+i, 20*hd+j), - )) - } - } - - for j := 0; j < 12*hd; j++ { - for k := 0; k < 4*hd; k++ { - r.polygons["torso"]["right"] = append(r.polygons["torso"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(16*hd+k, 20*hd+j), - )) - r.polygons["torso"]["left"] = append(r.polygons["torso"]["left"], NewPolygon( - [4]*Point{ - volumePoints[8*hd][j][k], - volumePoints[8*hd][j][k+1], - volumePoints[8*hd][j+1][k+1], - volumePoints[8*hd][j+1][k], - }, - r.getPixelColor((32*hd-1)-k, 20*hd+j), - )) - } - } - - for i := 0; i < 8*hd; i++ { - for k := 0; k < 4*hd; k++ { - r.polygons["torso"]["top"] = append(r.polygons["torso"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(20*hd+i, 16*hd+k), - )) - r.polygons["torso"]["bottom"] = append(r.polygons["torso"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][12*hd][k], - volumePoints[i+1][12*hd][k], - volumePoints[i+1][12*hd][k+1], - volumePoints[i][12*hd][k+1], - }, - r.getPixelColor(28*hd+i, (20*hd-1)-k), - )) - } - } -} - -// generateRightArmPolygons 生成右臂多边形 -func (r *SkinRenderer) generateRightArmPolygons(hd int) { - armWidth := 4 * hd - if r.isAlex { - armWidth = 3 * hd - } - - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= armWidth; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 12*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - volumePoints[i][j][0] = NewPoint(float64(i-4*hd), float64(j+8*hd), 0) - volumePoints[i][j][4*hd] = NewPoint(float64(i-4*hd), float64(j+8*hd), float64(4*hd)) - } - } - - for j := 0; j <= 12*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[armWidth][j] == nil { - volumePoints[armWidth][j] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(float64(-4*hd), float64(j+8*hd), float64(k)) - } - if volumePoints[armWidth][j][k] == nil { - volumePoints[armWidth][j][k] = NewPoint(float64(armWidth-4*hd), float64(j+8*hd), float64(k)) - } - } - } - - for i := 0; i <= armWidth; i++ { - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][12*hd] == nil { - volumePoints[i][12*hd] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i-4*hd), float64(8*hd), float64(k)) - } - if volumePoints[i][12*hd][k] == nil { - volumePoints[i][12*hd][k] = NewPoint(float64(i-4*hd), float64(12*hd+8*hd), float64(k)) - } - } - } - - // 前后面 - for i := 0; i < armWidth; i++ { - for j := 0; j < 12*hd; j++ { - var backX, frontX int - if r.isAlex { - backX = (51*hd - 1) - i - frontX = 44*hd + i - } else { - backX = (56*hd - 1) - i - frontX = 44*hd + i - } - - r.polygons["rightArm"]["back"] = append(r.polygons["rightArm"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor(backX, 20*hd+j), - )) - r.polygons["rightArm"]["front"] = append(r.polygons["rightArm"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(frontX, 20*hd+j), - )) - } - } - - // 左右面 - for j := 0; j < 12*hd; j++ { - for k := 0; k < 4*hd; k++ { - var rightOffsetX, leftOffsetX int - if r.isAlex { - rightOffsetX = 47 * hd - leftOffsetX = 40 * hd - } else { - rightOffsetX = 40 * hd - leftOffsetX = 52 * hd - } - - r.polygons["rightArm"]["right"] = append(r.polygons["rightArm"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(rightOffsetX+k, 20*hd+j), - )) - r.polygons["rightArm"]["left"] = append(r.polygons["rightArm"]["left"], NewPolygon( - [4]*Point{ - volumePoints[armWidth][j][k], - volumePoints[armWidth][j][k+1], - volumePoints[armWidth][j+1][k+1], - volumePoints[armWidth][j+1][k], - }, - r.getPixelColor((leftOffsetX-1)-k, 20*hd+j), - )) - } - } - - // 上下面 - for i := 0; i < armWidth; i++ { - for k := 0; k < 4*hd; k++ { - var topX, bottomX int - if r.isAlex { - topX = 44*hd + i - bottomX = 47*hd + i - } else { - topX = 44*hd + i - bottomX = 48*hd + i - } - - r.polygons["rightArm"]["top"] = append(r.polygons["rightArm"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(topX, 16*hd+k), - )) - r.polygons["rightArm"]["bottom"] = append(r.polygons["rightArm"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][12*hd][k], - volumePoints[i+1][12*hd][k], - volumePoints[i+1][12*hd][k+1], - volumePoints[i][12*hd][k+1], - }, - r.getPixelColor(bottomX, 16*hd+k), - )) - } - } -} - -// generateLeftArmPolygons 生成左臂多边形 -func (r *SkinRenderer) generateLeftArmPolygons(hd int) { - armWidth := 4 * hd - if r.isAlex { - armWidth = 3 * hd - } - - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= armWidth; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 12*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - volumePoints[i][j][0] = NewPoint(float64(i+8*hd), float64(j+8*hd), 0) - volumePoints[i][j][4*hd] = NewPoint(float64(i+8*hd), float64(j+8*hd), float64(4*hd)) - } - } - - for j := 0; j <= 12*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[armWidth][j] == nil { - volumePoints[armWidth][j] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(float64(8*hd), float64(j+8*hd), float64(k)) - } - if volumePoints[armWidth][j][k] == nil { - volumePoints[armWidth][j][k] = NewPoint(float64(armWidth+8*hd), float64(j+8*hd), float64(k)) - } - } - } - - for i := 0; i <= armWidth; i++ { - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][12*hd] == nil { - volumePoints[i][12*hd] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i+8*hd), float64(8*hd), float64(k)) - } - if volumePoints[i][12*hd][k] == nil { - volumePoints[i][12*hd][k] = NewPoint(float64(i+8*hd), float64(12*hd+8*hd), float64(k)) - } - } - } - - // 前后面 - for i := 0; i < armWidth; i++ { - for j := 0; j < 12*hd; j++ { - var color1, color2 int - - if r.isAlex { - color1 = 43*hd + i - color2 = 36*hd + i - r.polygons["leftArm"]["back"] = append(r.polygons["leftArm"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor(color1, 52*hd+j), - )) - r.polygons["leftArm"]["front"] = append(r.polygons["leftArm"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(color2, 52*hd+j), - )) - } else { - if r.isNewSkinType { - color1 = 47*hd - i // from right to left - color2 = 36*hd + i // from left to right - r.polygons["leftArm"]["back"] = append(r.polygons["leftArm"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor(color1, 52*hd+j), - )) - r.polygons["leftArm"]["front"] = append(r.polygons["leftArm"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(color2, 52*hd+j), - )) - } else { - // 旧版皮肤镜像右臂 - color1 = (56*hd - 1) - ((4*hd - 1) - i) - color2 = 44*hd + ((4*hd - 1) - i) - r.polygons["leftArm"]["back"] = append(r.polygons["leftArm"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor(color1, 20*hd+j), - )) - r.polygons["leftArm"]["front"] = append(r.polygons["leftArm"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(color2, 20*hd+j), - )) - } - } - } - } - - // 左右面 - for j := 0; j < 12*hd; j++ { - for k := 0; k < 4*hd; k++ { - var color1X, color2X, color1Y, color2Y int - - if r.isNewSkinType { - color1X = 32*hd + k - color2X = 43*hd - k - color1Y = 52*hd + j - color2Y = 52*hd + j - } else { - color1X = 40*hd + ((4*hd - 1) - k) - color2X = (52*hd - 1) - ((4*hd - 1) - k) - color1Y = 20*hd + j - color2Y = 20*hd + j - } - - r.polygons["leftArm"]["right"] = append(r.polygons["leftArm"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(color1X, color1Y), - )) - r.polygons["leftArm"]["left"] = append(r.polygons["leftArm"]["left"], NewPolygon( - [4]*Point{ - volumePoints[armWidth][j][k], - volumePoints[armWidth][j][k+1], - volumePoints[armWidth][j+1][k+1], - volumePoints[armWidth][j+1][k], - }, - r.getPixelColor(color2X, color2Y), - )) - } - } - - // 上下面 - for i := 0; i < armWidth; i++ { - for k := 0; k < 4*hd; k++ { - var topX, topY, bottomX, bottomY int - - if r.isAlex { - topX = 36*hd + i - topY = 48*hd + k - bottomX = 39*hd + i - bottomY = 48*hd + k - } else if r.isNewSkinType { - topX = 36*hd + i - topY = 48*hd + k - bottomX = 40*hd + i - bottomY = 48*hd + k - } else { - topX = 44*hd + ((4*hd - 1) - i) - topY = 16*hd + k - bottomX = 48*hd + ((4*hd - 1) - i) - bottomY = (20*hd - 1) - k - } - - r.polygons["leftArm"]["top"] = append(r.polygons["leftArm"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(topX, topY), - )) - r.polygons["leftArm"]["bottom"] = append(r.polygons["leftArm"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][12*hd][k], - volumePoints[i+1][12*hd][k], - volumePoints[i+1][12*hd][k+1], - volumePoints[i][12*hd][k+1], - }, - r.getPixelColor(bottomX, bottomY), - )) - } - } -} - -// generateRightLegPolygons 生成右腿多边形 -func (r *SkinRenderer) generateRightLegPolygons(hd int) { - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= 4*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 12*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - volumePoints[i][j][0] = NewPoint(float64(i), float64(j+20*hd), 0) - volumePoints[i][j][4*hd] = NewPoint(float64(i), float64(j+20*hd), float64(4*hd)) - } - } - - for j := 0; j <= 12*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[4*hd][j] == nil { - volumePoints[4*hd][j] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(0, float64(j+20*hd), float64(k)) - } - if volumePoints[4*hd][j][k] == nil { - volumePoints[4*hd][j][k] = NewPoint(float64(4*hd), float64(j+20*hd), float64(k)) - } - } - } - - for i := 0; i <= 4*hd; i++ { - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][12*hd] == nil { - volumePoints[i][12*hd] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i), float64(20*hd), float64(k)) - } - if volumePoints[i][12*hd][k] == nil { - volumePoints[i][12*hd][k] = NewPoint(float64(i), float64(12*hd+20*hd), float64(k)) - } - } - } - - for i := 0; i < 4*hd; i++ { - for j := 0; j < 12*hd; j++ { - r.polygons["rightLeg"]["back"] = append(r.polygons["rightLeg"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor((16*hd-1)-i, 20*hd+j), - )) - r.polygons["rightLeg"]["front"] = append(r.polygons["rightLeg"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(4*hd+i, 20*hd+j), - )) - } - } - - for j := 0; j < 12*hd; j++ { - for k := 0; k < 4*hd; k++ { - r.polygons["rightLeg"]["right"] = append(r.polygons["rightLeg"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(k, 20*hd+j), - )) - r.polygons["rightLeg"]["left"] = append(r.polygons["rightLeg"]["left"], NewPolygon( - [4]*Point{ - volumePoints[4*hd][j][k], - volumePoints[4*hd][j][k+1], - volumePoints[4*hd][j+1][k+1], - volumePoints[4*hd][j+1][k], - }, - r.getPixelColor((12*hd-1)-k, 20*hd+j), - )) - } - } - - for i := 0; i < 4*hd; i++ { - for k := 0; k < 4*hd; k++ { - r.polygons["rightLeg"]["top"] = append(r.polygons["rightLeg"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(4*hd+i, 16*hd+k), - )) - r.polygons["rightLeg"]["bottom"] = append(r.polygons["rightLeg"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][12*hd][k], - volumePoints[i+1][12*hd][k], - volumePoints[i+1][12*hd][k+1], - volumePoints[i][12*hd][k+1], - }, - r.getPixelColor(8*hd+i, 16*hd+k), - )) - } - } -} - -// generateLeftLegPolygons 生成左腿多边形 -func (r *SkinRenderer) generateLeftLegPolygons(hd int) { - volumePoints := make(map[int]map[int]map[int]*Point) - - for i := 0; i <= 4*hd; i++ { - if volumePoints[i] == nil { - volumePoints[i] = make(map[int]map[int]*Point) - } - for j := 0; j <= 12*hd; j++ { - if volumePoints[i][j] == nil { - volumePoints[i][j] = make(map[int]*Point) - } - volumePoints[i][j][0] = NewPoint(float64(i+4*hd), float64(j+20*hd), 0) - volumePoints[i][j][4*hd] = NewPoint(float64(i+4*hd), float64(j+20*hd), float64(4*hd)) - } - } - - for j := 0; j <= 12*hd; j++ { - if volumePoints[0][j] == nil { - volumePoints[0][j] = make(map[int]*Point) - } - if volumePoints[4*hd][j] == nil { - volumePoints[4*hd][j] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[0][j][k] == nil { - volumePoints[0][j][k] = NewPoint(float64(4*hd), float64(j+20*hd), float64(k)) - } - if volumePoints[4*hd][j][k] == nil { - volumePoints[4*hd][j][k] = NewPoint(float64(4*hd+4*hd), float64(j+20*hd), float64(k)) - } - } - } - - for i := 0; i <= 4*hd; i++ { - if volumePoints[i][0] == nil { - volumePoints[i][0] = make(map[int]*Point) - } - if volumePoints[i][12*hd] == nil { - volumePoints[i][12*hd] = make(map[int]*Point) - } - for k := 0; k <= 4*hd; k++ { - if volumePoints[i][0][k] == nil { - volumePoints[i][0][k] = NewPoint(float64(i+4*hd), float64(20*hd), float64(k)) - } - if volumePoints[i][12*hd][k] == nil { - volumePoints[i][12*hd][k] = NewPoint(float64(i+4*hd), float64(12*hd+20*hd), float64(k)) - } - } - } - - for i := 0; i < 4*hd; i++ { - for j := 0; j < 12*hd; j++ { - var color1X, color2X, color1Y, color2Y int - - if r.isNewSkinType { - color1X = 31*hd - i // from right to left - color2X = 20*hd + i // from left to right - color1Y = 52*hd + j - color2Y = 52*hd + j - } else { - color1X = (16*hd - 1) - ((4*hd - 1) - i) - color2X = 4*hd + ((4*hd - 1) - i) - color1Y = 20*hd + j - color2Y = 20*hd + j - } - - r.polygons["leftLeg"]["back"] = append(r.polygons["leftLeg"]["back"], NewPolygon( - [4]*Point{ - volumePoints[i][j][0], - volumePoints[i+1][j][0], - volumePoints[i+1][j+1][0], - volumePoints[i][j+1][0], - }, - r.getPixelColor(color1X, color1Y), - )) - r.polygons["leftLeg"]["front"] = append(r.polygons["leftLeg"]["front"], NewPolygon( - [4]*Point{ - volumePoints[i][j][4*hd], - volumePoints[i+1][j][4*hd], - volumePoints[i+1][j+1][4*hd], - volumePoints[i][j+1][4*hd], - }, - r.getPixelColor(color2X, color2Y), - )) - } - } - - for j := 0; j < 12*hd; j++ { - for k := 0; k < 4*hd; k++ { - var color1X, color2X, color1Y, color2Y int - - if r.isNewSkinType { - color1X = 16*hd + k // from left to right - color2X = 27*hd - k // from right to left - color1Y = 52*hd + j - color2Y = 52*hd + j - } else { - color1X = ((4*hd - 1) - k) - color2X = (12*hd - 1) - ((4*hd - 1) - k) - color1Y = 20*hd + j - color2Y = 20*hd + j - } - - r.polygons["leftLeg"]["right"] = append(r.polygons["leftLeg"]["right"], NewPolygon( - [4]*Point{ - volumePoints[0][j][k], - volumePoints[0][j][k+1], - volumePoints[0][j+1][k+1], - volumePoints[0][j+1][k], - }, - r.getPixelColor(color1X, color1Y), - )) - r.polygons["leftLeg"]["left"] = append(r.polygons["leftLeg"]["left"], NewPolygon( - [4]*Point{ - volumePoints[4*hd][j][k], - volumePoints[4*hd][j][k+1], - volumePoints[4*hd][j+1][k+1], - volumePoints[4*hd][j+1][k], - }, - r.getPixelColor(color2X, color2Y), - )) - } - } - - for i := 0; i < 4*hd; i++ { - for k := 0; k < 4*hd; k++ { - var topX, topY, bottomX, bottomY int - - if r.isNewSkinType { - topX = 20*hd + i - topY = 48*hd + k - bottomX = 24*hd + i - bottomY = 48*hd + k - } else { - topX = 4*hd + ((4*hd - 1) - i) - topY = 16*hd + k - bottomX = 8*hd + ((4*hd - 1) - i) - bottomY = (20*hd - 1) - k - } - - r.polygons["leftLeg"]["top"] = append(r.polygons["leftLeg"]["top"], NewPolygon( - [4]*Point{ - volumePoints[i][0][k], - volumePoints[i+1][0][k], - volumePoints[i+1][0][k+1], - volumePoints[i][0][k+1], - }, - r.getPixelColor(topX, topY), - )) - r.polygons["leftLeg"]["bottom"] = append(r.polygons["leftLeg"]["bottom"], NewPolygon( - [4]*Point{ - volumePoints[i][12*hd][k], - volumePoints[i+1][12*hd][k], - volumePoints[i+1][12*hd][k+1], - volumePoints[i][12*hd][k+1], - }, - r.getPixelColor(bottomX, bottomY), - )) - } - } -} diff --git a/internal/service/skin_renderer/renderer.go b/internal/service/skin_renderer/renderer.go deleted file mode 100644 index 4e70260..0000000 --- a/internal/service/skin_renderer/renderer.go +++ /dev/null @@ -1,591 +0,0 @@ -package skin_renderer - -import ( - "bytes" - "image" - "image/color" - "image/png" - "math" -) - -// SkinRenderer 皮肤渲染器 -type SkinRenderer struct { - playerSkin image.Image - isNewSkinType bool - isAlex bool - hdRatio int - - // 旋转参数 - ratio float64 - headOnly bool - hR float64 // 水平旋转角度 - vR float64 // 垂直旋转角度 - hrh float64 // 头部水平旋转 - vrll float64 // 左腿垂直旋转 - vrrl float64 // 右腿垂直旋转 - vrla float64 // 左臂垂直旋转 - vrra float64 // 右臂垂直旋转 - layers bool // 是否渲染第二层 - - // 计算后的三角函数值 - cosAlpha, sinAlpha float64 - cosOmega, sinOmega float64 - - // 边界 - minX, maxX, minY, maxY float64 - - // 各部件的旋转角度 - membersAngles map[string]angleSet - - // 可见面 - visibleFaces map[string]faceVisibility - frontFaces []string - backFaces []string - - // 多边形 - polygons map[string]map[string][]*Polygon -} - -type angleSet struct { - cosAlpha, sinAlpha float64 - cosOmega, sinOmega float64 -} - -type faceVisibility struct { - front []string - back []string -} - -var allFaces = []string{"back", "right", "top", "front", "left", "bottom"} - -// NewSkinRenderer 创建皮肤渲染器 -func NewSkinRenderer(ratio float64, headOnly bool, horizontalRotation, verticalRotation float64) *SkinRenderer { - return &SkinRenderer{ - ratio: ratio, - headOnly: headOnly, - hR: horizontalRotation, - vR: verticalRotation, - hrh: 0, - vrll: 0, - vrrl: 0, - vrla: 0, - vrra: 0, - layers: true, - } -} - -// NewSkinRendererFull 创建带完整参数的皮肤渲染器 -func NewSkinRendererFull(ratio float64, headOnly bool, hR, vR, hrh, vrll, vrrl, vrla, vrra float64, layers bool) *SkinRenderer { - return &SkinRenderer{ - ratio: ratio, - headOnly: headOnly, - hR: hR, - vR: vR, - hrh: hrh, - vrll: vrll, - vrrl: vrrl, - vrla: vrla, - vrra: vrra, - layers: layers, - } -} - -// Render 渲染皮肤 -func (r *SkinRenderer) Render(skinData []byte, isAlex bool) (image.Image, error) { - // 解码皮肤图像 - img, err := png.Decode(bytes.NewReader(skinData)) - if err != nil { - return nil, err - } - - r.playerSkin = img - r.isAlex = isAlex - - // 计算 HD 比例 - sourceWidth := img.Bounds().Dx() - sourceHeight := img.Bounds().Dy() - - // 防止内存溢出,限制最大尺寸 - if sourceWidth > 256 { - r.playerSkin = resizeImage(img, 256, sourceHeight*256/sourceWidth) - } - - r.hdRatio = r.playerSkin.Bounds().Dx() / 64 - - // 检查是否为新版皮肤格式(64x64) - if r.playerSkin.Bounds().Dx() == r.playerSkin.Bounds().Dy() { - r.isNewSkinType = true - } - - // 转换为 RGBA - r.playerSkin = convertToRGBA(r.playerSkin) - - // 处理背景透明 - r.makeBackgroundTransparent() - - // 计算角度 - r.calculateAngles() - - // 确定可见面 - r.facesDetermination() - - // 生成多边形 - r.generatePolygons() - - // 部件旋转 - r.memberRotation() - - // 创建投影 - r.createProjectionPlan() - - // 渲染图像 - return r.displayImage(), nil -} - -// makeBackgroundTransparent 处理背景透明 -func (r *SkinRenderer) makeBackgroundTransparent() { - rgba, ok := r.playerSkin.(*image.RGBA) - if !ok { - return - } - - // 检查左上角 8x8 区域是否为纯色 - var tempColor color.RGBA - needRemove := true - first := true - - for y := 0; y < 8; y++ { - for x := 0; x < 8; x++ { - c := rgba.RGBAAt(x, y) - - // 如果已有透明度,不需要处理 - if c.A < 128 { - needRemove = false - break - } - - if first { - tempColor = c - first = false - } else if c != tempColor { - needRemove = false - break - } - } - if !needRemove { - break - } - } - - if !needRemove { - return - } - - // 将该颜色设为透明 - bounds := rgba.Bounds() - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - c := rgba.RGBAAt(x, y) - if c.R == tempColor.R && c.G == tempColor.G && c.B == tempColor.B { - rgba.SetRGBA(x, y, color.RGBA{0, 0, 0, 0}) - } - } - } -} - -// calculateAngles 计算旋转角度 -func (r *SkinRenderer) calculateAngles() { - // 转换为弧度 - alpha := r.vR * math.Pi / 180 - omega := r.hR * math.Pi / 180 - - r.cosAlpha = math.Cos(alpha) - r.sinAlpha = math.Sin(alpha) - r.cosOmega = math.Cos(omega) - r.sinOmega = math.Sin(omega) - - r.membersAngles = make(map[string]angleSet) - - // 躯干不旋转 - r.membersAngles["torso"] = angleSet{ - cosAlpha: 1, sinAlpha: 0, - cosOmega: 1, sinOmega: 0, - } - - // 头部旋转 - omegaHead := r.hrh * math.Pi / 180 - r.membersAngles["head"] = angleSet{ - cosAlpha: 1, sinAlpha: 0, - cosOmega: math.Cos(omegaHead), sinOmega: math.Sin(omegaHead), - } - r.membersAngles["helmet"] = r.membersAngles["head"] - - // 右臂旋转 - alphaRightArm := r.vrra * math.Pi / 180 - r.membersAngles["rightArm"] = angleSet{ - cosAlpha: math.Cos(alphaRightArm), sinAlpha: math.Sin(alphaRightArm), - cosOmega: 1, sinOmega: 0, - } - - // 左臂旋转 - alphaLeftArm := r.vrla * math.Pi / 180 - r.membersAngles["leftArm"] = angleSet{ - cosAlpha: math.Cos(alphaLeftArm), sinAlpha: math.Sin(alphaLeftArm), - cosOmega: 1, sinOmega: 0, - } - - // 右腿旋转 - alphaRightLeg := r.vrrl * math.Pi / 180 - r.membersAngles["rightLeg"] = angleSet{ - cosAlpha: math.Cos(alphaRightLeg), sinAlpha: math.Sin(alphaRightLeg), - cosOmega: 1, sinOmega: 0, - } - - // 左腿旋转 - alphaLeftLeg := r.vrll * math.Pi / 180 - r.membersAngles["leftLeg"] = angleSet{ - cosAlpha: math.Cos(alphaLeftLeg), sinAlpha: math.Sin(alphaLeftLeg), - cosOmega: 1, sinOmega: 0, - } - - r.minX, r.maxX = 0, 0 - r.minY, r.maxY = 0, 0 -} - -// facesDetermination 确定可见面 -func (r *SkinRenderer) facesDetermination() { - r.visibleFaces = make(map[string]faceVisibility) - - parts := []string{"head", "torso", "rightArm", "leftArm", "rightLeg", "leftLeg"} - - for _, part := range parts { - angles := r.membersAngles[part] - - // 创建测试立方体点 - cubePoints := r.createCubePoints() - - var maxDepthPoint *Point - var maxDepthFaces []string - - for _, cp := range cubePoints { - point := cp.point - point.PreProject(0, 0, 0, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY) - - if maxDepthPoint == nil { - maxDepthPoint = point - maxDepthFaces = cp.faces - } else { - _, _, z1 := maxDepthPoint.GetDestCoord() - _, _, z2 := point.GetDestCoord() - if z1 > z2 { - maxDepthPoint = point - maxDepthFaces = cp.faces - } - } - } - - r.visibleFaces[part] = faceVisibility{ - back: maxDepthFaces, - front: diffFaces(allFaces, maxDepthFaces), - } - } - - // 确定全局前后面 - cubePoints := r.createCubePoints() - var maxDepthPoint *Point - var maxDepthFaces []string - - for _, cp := range cubePoints { - point := cp.point - point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY) - - if maxDepthPoint == nil { - maxDepthPoint = point - maxDepthFaces = cp.faces - } else { - _, _, z1 := maxDepthPoint.GetDestCoord() - _, _, z2 := point.GetDestCoord() - if z1 > z2 { - maxDepthPoint = point - maxDepthFaces = cp.faces - } - } - } - - r.backFaces = maxDepthFaces - r.frontFaces = diffFaces(allFaces, maxDepthFaces) -} - -type cubePoint struct { - point *Point - faces []string -} - -func (r *SkinRenderer) createCubePoints() []cubePoint { - return []cubePoint{ - {NewPoint(0, 0, 0), []string{"back", "right", "top"}}, - {NewPoint(0, 0, 1), []string{"front", "right", "top"}}, - {NewPoint(0, 1, 0), []string{"back", "right", "bottom"}}, - {NewPoint(0, 1, 1), []string{"front", "right", "bottom"}}, - {NewPoint(1, 0, 0), []string{"back", "left", "top"}}, - {NewPoint(1, 0, 1), []string{"front", "left", "top"}}, - {NewPoint(1, 1, 0), []string{"back", "left", "bottom"}}, - {NewPoint(1, 1, 1), []string{"front", "left", "bottom"}}, - } -} - -func diffFaces(all, exclude []string) []string { - excludeMap := make(map[string]bool) - for _, f := range exclude { - excludeMap[f] = true - } - - var result []string - for _, f := range all { - if !excludeMap[f] { - result = append(result, f) - } - } - return result -} - -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - -// memberRotation 部件旋转 -func (r *SkinRenderer) memberRotation() { - hd := float64(r.hdRatio) - - // 头部和头盔旋转 - angles := r.membersAngles["head"] - for _, face := range r.polygons["head"] { - for _, poly := range face { - poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - } - } - for _, face := range r.polygons["helmet"] { - for _, poly := range face { - poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - } - } - - if r.headOnly { - return - } - - // 右臂旋转 - angles = r.membersAngles["rightArm"] - for _, face := range r.polygons["rightArm"] { - for _, poly := range face { - poly.PreProject(-2*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - } - } - - // 左臂旋转 - angles = r.membersAngles["leftArm"] - for _, face := range r.polygons["leftArm"] { - for _, poly := range face { - poly.PreProject(10*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - } - } - - // 右腿旋转 - angles = r.membersAngles["rightLeg"] - zOffset := 4 * hd - if angles.sinAlpha < 0 { - zOffset = 0 - } - for _, face := range r.polygons["rightLeg"] { - for _, poly := range face { - poly.PreProject(2*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - } - } - - // 左腿旋转 - angles = r.membersAngles["leftLeg"] - zOffset = 4 * hd - if angles.sinAlpha < 0 { - zOffset = 0 - } - for _, face := range r.polygons["leftLeg"] { - for _, poly := range face { - poly.PreProject(6*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega) - } - } -} - -// createProjectionPlan 创建投影 -func (r *SkinRenderer) createProjectionPlan() { - for _, piece := range r.polygons { - for _, face := range piece { - for _, poly := range face { - if !poly.IsProjected() { - poly.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY) - } - } - } - } -} - -// displayImage 渲染最终图像 -func (r *SkinRenderer) displayImage() image.Image { - width := r.maxX - r.minX - height := r.maxY - r.minY - ratio := r.ratio * 2 - - srcWidth := int(ratio*width) + 1 - srcHeight := int(ratio*height) + 1 - - img := image.NewRGBA(image.Rect(0, 0, srcWidth, srcHeight)) - - // 按深度顺序绘制 - displayOrder := r.getDisplayOrder() - - for _, order := range displayOrder { - for piece, faces := range order { - for _, face := range faces { - if polys, ok := r.polygons[piece][face]; ok { - for _, poly := range polys { - poly.AddToImage(img, r.minX, r.minY, ratio) - } - } - } - } - } - - // 抗锯齿:2x 渲染后缩小 - realWidth := srcWidth / 2 - realHeight := srcHeight / 2 - destImg := resizeImage(img, realWidth, realHeight) - - return destImg -} - -// getDisplayOrder 获取绘制顺序 -func (r *SkinRenderer) getDisplayOrder() []map[string][]string { - var displayOrder []map[string][]string - - if contains(r.frontFaces, "top") { - if contains(r.frontFaces, "right") { - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front}) - } else { - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front}) - } - - displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front}) - displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front}) - } else { - displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front}) - displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front}) - - if contains(r.frontFaces, "right") { - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front}) - } else { - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces}) - displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front}) - } - } - - return displayOrder -} - -// 辅助函数 - -func convertToRGBA(img image.Image) *image.RGBA { - if rgba, ok := img.(*image.RGBA); ok { - return rgba - } - - bounds := img.Bounds() - rgba := image.NewRGBA(bounds) - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - rgba.Set(x, y, img.At(x, y)) - } - } - return rgba -} - -func resizeImage(img image.Image, width, height int) *image.RGBA { - bounds := img.Bounds() - srcW := bounds.Dx() - srcH := bounds.Dy() - - dst := image.NewRGBA(image.Rect(0, 0, width, height)) - - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - srcX := bounds.Min.X + x*srcW/width - srcY := bounds.Min.Y + y*srcH/height - dst.Set(x, y, img.At(srcX, srcY)) - } - } - - return dst -} - -// getPixelColor 从皮肤图像获取像素颜色 -func (r *SkinRenderer) getPixelColor(x, y int) color.RGBA { - if x < 0 || y < 0 { - return color.RGBA{} - } - bounds := r.playerSkin.Bounds() - if x >= bounds.Dx() || y >= bounds.Dy() { - return color.RGBA{} - } - - c := r.playerSkin.At(bounds.Min.X+x, bounds.Min.Y+y) - r32, g32, b32, a32 := c.RGBA() - return color.RGBA{ - R: uint8(r32 >> 8), - G: uint8(g32 >> 8), - B: uint8(b32 >> 8), - A: uint8(a32 >> 8), - } -} diff --git a/internal/service/skin_renderer/renderer_test.go b/internal/service/skin_renderer/renderer_test.go deleted file mode 100644 index a915460..0000000 --- a/internal/service/skin_renderer/renderer_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package skin_renderer - -import ( - "image" - "image/color" - "image/png" - "os" - "testing" -) - -// createTestSkin 创建一个测试用的 64x64 皮肤图像 -func createTestSkin() []byte { - img := image.NewRGBA(image.Rect(0, 0, 64, 64)) - - // 填充一些测试颜色 - // 头部区域 (8,8) - (16,16) - for y := 8; y < 16; y++ { - for x := 8; x < 16; x++ { - img.Set(x, y, image.White) - } - } - - // 躯干区域 (20,20) - (28,32) - for y := 20; y < 32; y++ { - for x := 20; x < 28; x++ { - img.Set(x, y, image.Black) - } - } - - // 编码为 PNG - f, _ := os.CreateTemp("", "test_skin_*.png") - defer os.Remove(f.Name()) - defer f.Close() - - png.Encode(f, img) - f.Seek(0, 0) - - data, _ := os.ReadFile(f.Name()) - return data -} - -func TestSkinRenderer_Render(t *testing.T) { - skinData := createTestSkin() - if len(skinData) == 0 { - t.Skip("无法创建测试皮肤") - } - - renderer := NewSkinRenderer(7.0, false, -45, -25) - result, err := renderer.Render(skinData, false) - if err != nil { - t.Fatalf("渲染失败: %v", err) - } - - if result == nil { - t.Fatal("渲染结果为空") - } - - bounds := result.Bounds() - if bounds.Dx() == 0 || bounds.Dy() == 0 { - t.Error("渲染结果尺寸为零") - } - - t.Logf("渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy()) -} - -func TestSkinRenderer_RenderHeadOnly(t *testing.T) { - skinData := createTestSkin() - if len(skinData) == 0 { - t.Skip("无法创建测试皮肤") - } - - renderer := NewSkinRenderer(15.0, true, 45, -25) - result, err := renderer.Render(skinData, false) - if err != nil { - t.Fatalf("渲染头像失败: %v", err) - } - - if result == nil { - t.Fatal("渲染结果为空") - } - - bounds := result.Bounds() - t.Logf("头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy()) -} - -func TestMinecraft_RenderSkin(t *testing.T) { - skinData := createTestSkin() - if len(skinData) == 0 { - t.Skip("无法创建测试皮肤") - } - - mc := NewMinecraft() - result, err := mc.RenderSkin(skinData, 7.0, false) - if err != nil { - t.Fatalf("RenderSkin 失败: %v", err) - } - - if result == nil { - t.Fatal("渲染结果为空") - } - - bounds := result.Bounds() - t.Logf("完整皮肤渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy()) -} - -func TestMinecraft_Render2DAvatar(t *testing.T) { - skinData := createTestSkin() - if len(skinData) == 0 { - t.Skip("无法创建测试皮肤") - } - - mc := NewMinecraft() - result, err := mc.Render2DAvatar(skinData, 15.0) - if err != nil { - t.Fatalf("Render2DAvatar 失败: %v", err) - } - - if result == nil { - t.Fatal("渲染结果为空") - } - - bounds := result.Bounds() - t.Logf("2D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy()) -} - -func TestMinecraft_Render3DAvatar(t *testing.T) { - skinData := createTestSkin() - if len(skinData) == 0 { - t.Skip("无法创建测试皮肤") - } - - mc := NewMinecraft() - result, err := mc.Render3DAvatar(skinData, 15.0) - if err != nil { - t.Fatalf("Render3DAvatar 失败: %v", err) - } - - if result == nil { - t.Fatal("渲染结果为空") - } - - bounds := result.Bounds() - t.Logf("3D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy()) -} - -func TestPoint_Project(t *testing.T) { - p := NewPoint(1, 2, 3) - - var minX, maxX, minY, maxY float64 - - // 测试 45 度旋转 - cosAlpha := 0.9063077870366499 // cos(-25°) - sinAlpha := -0.42261826174069944 // sin(-25°) - cosOmega := 0.7071067811865476 // cos(45°) - sinOmega := 0.7071067811865476 // sin(45°) - - p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, &minX, &maxX, &minY, &maxY) - - x, y, z := p.GetDestCoord() - t.Logf("投影结果: x=%.2f, y=%.2f, z=%.2f", x, y, z) - - if !p.IsProjected() { - t.Error("点应该标记为已投影") - } -} - -func TestPolygon_AddToImage(t *testing.T) { - // 创建一个简单的正方形多边形 - p1 := NewPoint(0, 0, 0) - p2 := NewPoint(10, 0, 0) - p3 := NewPoint(10, 10, 0) - p4 := NewPoint(0, 10, 0) - - var minX, maxX, minY, maxY float64 - p1.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY) - p2.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY) - p3.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY) - p4.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY) - - poly := NewPolygon([4]*Point{p1, p2, p3, p4}, color.RGBA{R: 255, G: 255, B: 255, A: 255}) - - img := image.NewRGBA(image.Rect(0, 0, 100, 100)) - poly.AddToImage(img, minX, minY, 5.0) - - // 检查是否有像素被绘制 - hasPixels := false - for y := 0; y < 100; y++ { - for x := 0; x < 100; x++ { - c := img.RGBAAt(x, y) - if c.A > 0 { - hasPixels = true - break - } - } - if hasPixels { - break - } - } - - if !hasPixels { - t.Error("多边形应该在图像上绘制了像素") - } -} diff --git a/internal/service/texture_render_service.go b/internal/service/texture_render_service.go deleted file mode 100644 index 8f4a9f4..0000000 --- a/internal/service/texture_render_service.go +++ /dev/null @@ -1,808 +0,0 @@ -package service - -import ( - "bytes" - "carrotskin/internal/model" - "carrotskin/internal/repository" - "carrotskin/internal/service/skin_renderer" - "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 - minecraft *skin_renderer.Minecraft // 3D 渲染器 -} - -// 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, - minecraft: skin_renderer.NewMinecraft(), - } -} - -// 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) - } - - // 使用新的 3D 渲染器 - var rendered image.Image - switch mode { - case AvatarMode3D: - // 使用 Blessing Skin 风格的 3D 头像渲染 - ratio := float64(size) / 15.0 // 基准比例 - rendered, err = s.minecraft.Render3DAvatar(textureData, ratio) - if err != nil { - s.logger.Warn("3D头像渲染失败,回退到2D", zap.Error(err)) - img, decErr := png.Decode(bytes.NewReader(textureData)) - if decErr != nil { - return nil, fmt.Errorf("解码PNG失败: %w", decErr) - } - rendered = s.renderHeadView(img, size) - } - default: - // 2D 头像使用新渲染器 - ratio := float64(size) / 15.0 - rendered, err = s.minecraft.Render2DAvatar(textureData, ratio) - if err != nil { - s.logger.Warn("2D头像渲染失败,回退到旧方法", zap.Error(err)) - img, decErr := png.Decode(bytes.NewReader(textureData)) - if decErr != nil { - return nil, fmt.Errorf("解码PNG失败: %w", decErr) - } - 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 渲染等距视图(使用 Blessing Skin 风格的真 3D 渲染) -func (s *textureRenderService) renderIsometricView(img image.Image, isSlim bool, size int) image.Image { - // 将图像编码为 PNG 数据 - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - // 编码失败,回退到简单渲染 - return s.renderIsometricViewFallback(img, isSlim, size) - } - - // 使用新的 3D 渲染器渲染完整皮肤 - ratio := float64(size) / 32.0 // 基准比例,32 像素高度的皮肤 - rendered, err := s.minecraft.RenderSkin(buf.Bytes(), ratio, isSlim) - if err != nil { - // 渲染失败,回退到简单渲染 - return s.renderIsometricViewFallback(img, isSlim, size) - } - - return rendered -} - -// renderIsometricViewFallback 等距视图回退方案(简单 2D) -func (s *textureRenderService) renderIsometricViewFallback(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 { - // 将图像编码为 PNG 数据 - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - // 编码失败,回退到简单缩放 - srcBounds := img.Bounds() - if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 { - return img - } - return scaleNearest(img, size*2, size) - } - - // 使用新的披风渲染器 - rendered, err := s.minecraft.RenderCape(buf.Bytes(), size) - if err != nil { - // 渲染失败,回退到简单缩放 - srcBounds := img.Bounds() - if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 { - return img - } - return scaleNearest(img, size*2, size) - } - - return rendered -} - -// 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 绘制单个分块(基础层+第二层,正确的 Alpha 混合) -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)) - } - } - - // 绘制第二层(使用 Alpha 混合) - 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 - overlayColor := overlay.At(srcX, srcY) - - // 获取 overlay 的 alpha 值 - _, _, _, a := overlayColor.RGBA() - if a == 0 { - // 完全透明,跳过 - continue - } - - if a == 0xFFFF { - // 完全不透明,直接覆盖 - dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, overlayColor) - } else { - // 半透明,进行 Alpha 混合 - baseColor := dst.At(dstRect.Min.X+x, dstRect.Min.Y+y) - blended := alphaBlendColors(baseColor, overlayColor) - dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, blended) - } - } - } - } -} - -// alphaBlendColors 执行 Alpha 混合 -func alphaBlendColors(dst, src color.Color) color.Color { - sr, sg, sb, sa := src.RGBA() - dr, dg, db, da := dst.RGBA() - - if sa == 0 { - return dst - } - if sa == 0xFFFF { - return src - } - - // Alpha 混合公式 - srcA := float64(sa) / 0xFFFF - dstA := float64(da) / 0xFFFF - outA := srcA + dstA*(1-srcA) - - if outA == 0 { - return color.RGBA{} - } - - outR := (float64(sr)*srcA + float64(dr)*dstA*(1-srcA)) / outA - outG := (float64(sg)*srcA + float64(dg)*dstA*(1-srcA)) / outA - outB := (float64(sb)*srcA + float64(db)*dstA*(1-srcA)) / outA - - return color.RGBA64{ - R: uint16(outR), - G: uint16(outG), - B: uint16(outB), - A: uint16(outA * 0xFFFF), - } -} - -// 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/user_service.go b/internal/service/user_service.go index 1575e43..336a199 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -27,7 +27,6 @@ import ( // userService UserService的实现 type userService struct { userRepo repository.UserRepository - configRepo repository.SystemConfigRepository jwtService *auth.JWTService redis *redis.Client cache *database.CacheManager @@ -40,7 +39,6 @@ type userService struct { // NewUserService 创建UserService实例 func NewUserService( userRepo repository.UserRepository, - configRepo repository.SystemConfigRepository, jwtService *auth.JWTService, redisClient *redis.Client, cacheManager *database.CacheManager, @@ -51,7 +49,6 @@ func NewUserService( // 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键 return &userService{ userRepo: userRepo, - configRepo: configRepo, jwtService: jwtService, redis: redisClient, cache: cacheManager, @@ -417,39 +414,29 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [ } func (s *userService) GetMaxProfilesPerUser() int { - config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user") - if err != nil || config == nil { + cfg, err := config.GetConfig() + if err != nil || cfg.Site.MaxProfilesPerUser <= 0 { return 5 } - var value int - fmt.Sscanf(config.Value, "%d", &value) - if value <= 0 { - return 5 - } - return value + return cfg.Site.MaxProfilesPerUser } func (s *userService) GetMaxTexturesPerUser() int { - config, err := s.configRepo.GetByKey(context.Background(), "max_textures_per_user") - if err != nil || config == nil { + cfg, err := config.GetConfig() + if err != nil || cfg.Site.MaxTexturesPerUser <= 0 { return 50 } - var value int - fmt.Sscanf(config.Value, "%d", &value) - if value <= 0 { - return 50 - } - return value + return cfg.Site.MaxTexturesPerUser } // 私有辅助方法 func (s *userService) getDefaultAvatar() string { - config, err := s.configRepo.GetByKey(context.Background(), "default_avatar") - if err != nil || config == nil || config.Value == "" { + cfg, err := config.GetConfig() + if err != nil { return "" } - return config.Value + return cfg.Site.DefaultAvatar } func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error { diff --git a/internal/service/user_service_test.go b/internal/service/user_service_test.go index daad043..f73d2c1 100644 --- a/internal/service/user_service_test.go +++ b/internal/service/user_service_test.go @@ -12,14 +12,13 @@ import ( func TestUserServiceImpl_Register(t *testing.T) { // 准备依赖 userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() // 初始化Service - // 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们 + // 注意:redisClient 和 storageClient 传入 nil,因为 Register 方法中没有使用它们 cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -114,7 +113,6 @@ func TestUserServiceImpl_Register(t *testing.T) { func TestUserServiceImpl_Login(t *testing.T) { // 准备依赖 userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() @@ -130,7 +128,7 @@ func TestUserServiceImpl_Login(t *testing.T) { _ = userRepo.Create(context.Background(), testUser) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -197,7 +195,6 @@ func TestUserServiceImpl_Login(t *testing.T) { // TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) { userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() @@ -211,7 +208,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) { _ = userRepo.Create(context.Background(), user) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -246,7 +243,6 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) { // TestUserServiceImpl_ChangePassword 测试 ChangePassword func TestUserServiceImpl_ChangePassword(t *testing.T) { userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() @@ -259,7 +255,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) { _ = userRepo.Create(context.Background(), user) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -282,7 +278,6 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) { // TestUserServiceImpl_ResetPassword 测试 ResetPassword func TestUserServiceImpl_ResetPassword(t *testing.T) { userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() @@ -294,7 +289,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) { _ = userRepo.Create(context.Background(), user) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -312,7 +307,6 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) { // TestUserServiceImpl_ChangeEmail 测试 ChangeEmail func TestUserServiceImpl_ChangeEmail(t *testing.T) { userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() @@ -322,7 +316,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) { _ = userRepo.Create(context.Background(), user2) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -340,12 +334,11 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) { // TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) { userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -373,30 +366,19 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) { } // TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser +// 现在配置从环境变量读取,测试默认值 func TestUserServiceImpl_MaxLimits(t *testing.T) { userRepo := NewMockUserRepository() - configRepo := NewMockSystemConfigRepository() jwtService := auth.NewJWTService("secret", 1) logger := zap.NewNop() // 未配置时走默认值 cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) + userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) if got := userService.GetMaxProfilesPerUser(); got != 5 { t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got) } if got := userService.GetMaxTexturesPerUser(); got != 50 { t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got) } - - // 配置有效值 - _ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_profiles_per_user", Value: "10"}) - _ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_textures_per_user", Value: "100"}) - - if got := userService.GetMaxProfilesPerUser(); got != 10 { - t.Fatalf("GetMaxProfilesPerUser 配置值错误, got=%d", got) - } - if got := userService.GetMaxTexturesPerUser(); got != 100 { - t.Fatalf("GetMaxTexturesPerUser 配置值错误, got=%d", got) - } } diff --git a/internal/types/common.go b/internal/types/common.go index 3e85f97..bff8eba 100644 --- a/internal/types/common.go +++ b/internal/types/common.go @@ -173,12 +173,3 @@ type SystemConfigResponse struct { MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"` MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"` } - -// RenderResponse 材质渲染响应 -type RenderResponse struct { - URL string `json:"url" example:"https://rustfs.example.com/renders/xxx.png"` - ContentType string `json:"content_type" example:"image/png"` - ETag string `json:"etag,omitempty" example:"abc123def456"` - Size int64 `json:"size" example:"2048"` - LastModified *time.Time `json:"last_modified,omitempty" example:"2025-10-01T12:00:00Z"` -} diff --git a/pkg/auth/casbin.go b/pkg/auth/casbin.go new file mode 100644 index 0000000..7c402c7 --- /dev/null +++ b/pkg/auth/casbin.go @@ -0,0 +1,124 @@ +package auth + +import ( + "fmt" + "sync" + + "github.com/casbin/casbin/v2" + gormadapter "github.com/casbin/gorm-adapter/v3" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// CasbinService Casbin权限服务 +type CasbinService struct { + enforcer *casbin.Enforcer + logger *zap.Logger + mu sync.RWMutex +} + +// NewCasbinService 创建Casbin服务 +func NewCasbinService(db *gorm.DB, modelPath string, logger *zap.Logger) (*CasbinService, error) { + // 使用Gorm适配器,自动使用casbin_rule表 + adapter, err := gormadapter.NewAdapterByDBUseTableName(db, "", "casbin_rule") + if err != nil { + return nil, fmt.Errorf("创建Casbin适配器失败: %w", err) + } + + // 创建Enforcer + enforcer, err := casbin.NewEnforcer(modelPath, adapter) + if err != nil { + return nil, fmt.Errorf("创建Casbin执行器失败: %w", err) + } + + // 加载策略 + if err := enforcer.LoadPolicy(); err != nil { + return nil, fmt.Errorf("加载Casbin策略失败: %w", err) + } + + logger.Info("Casbin权限服务初始化成功") + + return &CasbinService{ + enforcer: enforcer, + logger: logger, + }, nil +} + +// Enforce 检查权限 +// sub: 主体(用户角色), obj: 资源, act: 操作 +func (s *CasbinService) Enforce(sub, obj, act string) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.enforcer.Enforce(sub, obj, act) +} + +// CheckPermission 检查用户权限(便捷方法) +func (s *CasbinService) CheckPermission(role, resource, action string) bool { + allowed, err := s.Enforce(role, resource, action) + if err != nil { + s.logger.Error("权限检查失败", + zap.String("role", role), + zap.String("resource", resource), + zap.String("action", action), + zap.Error(err), + ) + return false + } + return allowed +} + +// AddPolicy 添加策略 +func (s *CasbinService) AddPolicy(sub, obj, act string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.enforcer.AddPolicy(sub, obj, act) +} + +// RemovePolicy 移除策略 +func (s *CasbinService) RemovePolicy(sub, obj, act string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.enforcer.RemovePolicy(sub, obj, act) +} + +// AddRoleForUser 为用户添加角色 +func (s *CasbinService) AddRoleForUser(user, role string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.enforcer.AddRoleForUser(user, role) +} + +// GetRolesForUser 获取用户的角色 +func (s *CasbinService) GetRolesForUser(user string) []string { + s.mu.RLock() + defer s.mu.RUnlock() + + roles, _ := s.enforcer.GetRolesForUser(user) + return roles +} + +// GetPermissionsForRole 获取角色的所有权限 +func (s *CasbinService) GetPermissionsForRole(role string) [][]string { + s.mu.RLock() + defer s.mu.RUnlock() + + perms, _ := s.enforcer.GetPermissionsForUser(role) + return perms +} + +// ReloadPolicy 重新加载策略 +func (s *CasbinService) ReloadPolicy() error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.enforcer.LoadPolicy() +} + +// GetEnforcer 获取原始Enforcer(用于高级操作) +func (s *CasbinService) GetEnforcer() *casbin.Enforcer { + return s.enforcer +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 21258ad..b11dd26 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -14,6 +14,7 @@ import ( // Config 应用配置结构体 type Config struct { Environment string `mapstructure:"environment"` + Site SiteConfig `mapstructure:"site"` Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` @@ -25,6 +26,18 @@ type Config struct { Security SecurityConfig `mapstructure:"security"` } +// SiteConfig 站点配置 +type SiteConfig struct { + Name string `mapstructure:"name"` + Description string `mapstructure:"description"` + RegistrationEnabled bool `mapstructure:"registration_enabled"` + DefaultAvatar string `mapstructure:"default_avatar"` + MaxTexturesPerUser int `mapstructure:"max_textures_per_user"` + MaxProfilesPerUser int `mapstructure:"max_profiles_per_user"` + CheckinReward int `mapstructure:"checkin_reward"` + TextureDownloadReward int `mapstructure:"texture_download_reward"` +} + // ServerConfig 服务器配置 type ServerConfig struct { Port string `mapstructure:"port"` @@ -201,6 +214,16 @@ func setDefaults() { // 安全默认配置 viper.SetDefault("security.allowed_origins", []string{"*"}) viper.SetDefault("security.allowed_domains", []string{"localhost", "127.0.0.1"}) + + // 站点默认配置 + viper.SetDefault("site.name", "CarrotSkin") + viper.SetDefault("site.description", "一个优秀的Minecraft皮肤站") + viper.SetDefault("site.registration_enabled", true) + viper.SetDefault("site.default_avatar", "") + viper.SetDefault("site.max_textures_per_user", 50) + viper.SetDefault("site.max_profiles_per_user", 5) + viper.SetDefault("site.checkin_reward", 10) + viper.SetDefault("site.texture_download_reward", 1) } // setupEnvMappings 设置环境变量映射 @@ -262,6 +285,16 @@ func setupEnvMappings() { viper.BindEnv("email.username", "EMAIL_USERNAME") viper.BindEnv("email.password", "EMAIL_PASSWORD") viper.BindEnv("email.from_name", "EMAIL_FROM_NAME") + + // 站点配置 + viper.BindEnv("site.name", "SITE_NAME") + viper.BindEnv("site.description", "SITE_DESCRIPTION") + viper.BindEnv("site.registration_enabled", "REGISTRATION_ENABLED") + viper.BindEnv("site.default_avatar", "DEFAULT_AVATAR") + viper.BindEnv("site.max_textures_per_user", "MAX_TEXTURES_PER_USER") + viper.BindEnv("site.max_profiles_per_user", "MAX_PROFILES_PER_USER") + viper.BindEnv("site.checkin_reward", "CHECKIN_REWARD") + viper.BindEnv("site.texture_download_reward", "TEXTURE_DOWNLOAD_REWARD") } // overrideFromEnv 从环境变量中覆盖配置 diff --git a/pkg/database/cache.go b/pkg/database/cache.go index 582fa8a..62dfafe 100644 --- a/pkg/database/cache.go +++ b/pkg/database/cache.go @@ -306,11 +306,6 @@ func (b *CacheKeyBuilder) TextureList(userID int64, page int) string { return fmt.Sprintf("%stexture:user:%d:page:%d", b.prefix, userID, page) } -// TextureRender 构建材质渲染缓存键 -func (b *CacheKeyBuilder) TextureRender(textureID int64, renderType string, size int) string { - return fmt.Sprintf("%stexture:render:%d:%s:%d", b.prefix, textureID, renderType, size) -} - // Token 构建令牌缓存键 func (b *CacheKeyBuilder) Token(accessToken string) string { return fmt.Sprintf("%stoken:%s", b.prefix, accessToken) diff --git a/pkg/database/manager.go b/pkg/database/manager.go index 033be4d..991b7fe 100644 --- a/pkg/database/manager.go +++ b/pkg/database/manager.go @@ -81,9 +81,6 @@ func AutoMigrate(logger *zap.Logger) error { // Yggdrasil相关表(在User之后创建,因为它引用User) &model.Yggdrasil{}, - // 系统配置表 - &model.SystemConfig{}, - // 审计日志表 &model.AuditLog{}, diff --git a/pkg/database/seed.go b/pkg/database/seed.go index 953bf11..77eba25 100644 --- a/pkg/database/seed.go +++ b/pkg/database/seed.go @@ -12,36 +12,41 @@ import ( const ( defaultAdminUsername = "admin" defaultAdminEmail = "admin@example.com" - defaultAdminPassword = "admin123456" // 首次登录后请立即修改 + defaultAdminPassword = "admin123456" // 首次登录后请立即修改,部署到生产环境后删除 ) -// defaultSystemConfigs 默认系统配置 -var defaultSystemConfigs = []model.SystemConfig{ - {Key: "site_name", Value: "CarrotSkin", Description: "网站名称", Type: model.ConfigTypeString, IsPublic: true}, - {Key: "site_description", Value: "一个优秀的Minecraft皮肤站", Description: "网站描述", Type: model.ConfigTypeString, IsPublic: true}, - {Key: "registration_enabled", Value: "true", Description: "是否允许用户注册", Type: model.ConfigTypeBoolean, IsPublic: true}, - {Key: "checkin_reward", Value: "10", Description: "签到奖励积分", Type: model.ConfigTypeInteger, IsPublic: true}, - {Key: "texture_download_reward", Value: "1", Description: "材质被下载奖励积分", Type: model.ConfigTypeInteger, IsPublic: false}, - {Key: "max_textures_per_user", Value: "50", Description: "每个用户最大材质数量", Type: model.ConfigTypeInteger, IsPublic: false}, - {Key: "max_profiles_per_user", Value: "5", Description: "每个用户最大角色数量", Type: model.ConfigTypeInteger, IsPublic: false}, - {Key: "default_avatar", Value: "", Description: "默认头像URL", Type: model.ConfigTypeString, IsPublic: true}, -} - // defaultCasbinRules 默认Casbin权限规则 +// 规则格式: {PType: "p", V0: "角色", V1: "资源", V2: "操作"} +// PType "p" 表示策略规则,"g" 表示角色继承 var defaultCasbinRules = []model.CasbinRule{ - // 管理员拥有所有权限 + // ==================== 管理员权限 ==================== + // 管理员拥有所有权限(通配符) {PType: "p", V0: "admin", V1: "*", V2: "*"}, - // 普通用户权限 - {PType: "p", V0: "user", V1: "texture", V2: "create"}, - {PType: "p", V0: "user", V1: "texture", V2: "read"}, - {PType: "p", V0: "user", V1: "texture", V2: "update_own"}, - {PType: "p", V0: "user", V1: "texture", V2: "delete_own"}, - {PType: "p", V0: "user", V1: "profile", V2: "create"}, - {PType: "p", V0: "user", V1: "profile", V2: "read"}, - {PType: "p", V0: "user", V1: "profile", V2: "update_own"}, - {PType: "p", V0: "user", V1: "profile", V2: "delete_own"}, - {PType: "p", V0: "user", V1: "user", V2: "update_own"}, - // 角色继承:admin 继承 user 的所有权限 + + // ==================== 普通用户权限 ==================== + // --- 用户资源 (user) --- + {PType: "p", V0: "user", V1: "user", V2: "read_own"}, // 查看自己的信息 + {PType: "p", V0: "user", V1: "user", V2: "update_own"}, // 更新自己的信息 + + // --- 材质资源 (texture) --- + {PType: "p", V0: "user", V1: "texture", V2: "read"}, // 查看材质(公开) + {PType: "p", V0: "user", V1: "texture", V2: "create"}, // 上传材质 + {PType: "p", V0: "user", V1: "texture", V2: "update_own"}, // 更新自己的材质 + {PType: "p", V0: "user", V1: "texture", V2: "delete_own"}, // 删除自己的材质 + {PType: "p", V0: "user", V1: "texture", V2: "favorite"}, // 收藏材质 + + // --- 档案资源 (profile) --- + {PType: "p", V0: "user", V1: "profile", V2: "read"}, // 查看档案(公开) + {PType: "p", V0: "user", V1: "profile", V2: "create"}, // 创建档案 + {PType: "p", V0: "user", V1: "profile", V2: "update_own"}, // 更新自己的档案 + {PType: "p", V0: "user", V1: "profile", V2: "delete_own"}, // 删除自己的档案 + + // --- Yggdrasil资源 (yggdrasil) --- + {PType: "p", V0: "user", V1: "yggdrasil", V2: "auth"}, // Yggdrasil认证 + {PType: "p", V0: "user", V1: "yggdrasil", V2: "reset_password"}, // 重置Yggdrasil密码 + + // ==================== 角色继承 ==================== + // admin 继承 user 的所有权限 {PType: "g", V0: "admin", V1: "user"}, } @@ -59,11 +64,6 @@ func Seed(logger *zap.Logger) error { return err } - // 初始化系统配置 - if err := seedSystemConfigs(db, logger); err != nil { - return err - } - // 初始化Casbin权限规则 if err := seedCasbinRules(db, logger); err != nil { return err @@ -119,23 +119,6 @@ func seedAdminUser(db *gorm.DB, logger *zap.Logger) error { return nil } -// seedSystemConfigs 初始化系统配置 -func seedSystemConfigs(db *gorm.DB, logger *zap.Logger) error { - for _, config := range defaultSystemConfigs { - // 使用 FirstOrCreate 避免重复插入 - var existing model.SystemConfig - result := db.Where("key = ?", config.Key).First(&existing) - if result.Error == gorm.ErrRecordNotFound { - if err := db.Create(&config).Error; err != nil { - logger.Error("创建系统配置失败", zap.String("key", config.Key), zap.Error(err)) - return err - } - logger.Info("创建系统配置", zap.String("key", config.Key)) - } - } - return nil -} - // seedCasbinRules 初始化Casbin权限规则 func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error { for _, rule := range defaultCasbinRules { @@ -153,4 +136,3 @@ func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error { } return nil } - diff --git a/scripts/check-env.sh b/scripts/check-env.sh deleted file mode 100644 index 87e0796..0000000 --- a/scripts/check-env.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# CarrotSkin 环境变量检查脚本 - -echo "🔍 检查 CarrotSkin 环境变量配置..." - -# 必需的环境变量列表 -REQUIRED_VARS=( - "DATABASE_HOST" - "DATABASE_USERNAME" - "DATABASE_PASSWORD" - "DATABASE_NAME" - "REDIS_HOST" - "RUSTFS_ENDPOINT" - "RUSTFS_ACCESS_KEY" - "RUSTFS_SECRET_KEY" - "RUSTFS_BUCKET_TEXTURES" - "RUSTFS_BUCKET_AVATARS" - "JWT_SECRET" -) - -# 检查.env文件是否存在 -if [ ! -f ".env" ]; then - echo "❌ .env 文件不存在" - echo "💡 请复制 .env.example 为 .env 并配置相关变量" - exit 1 -fi - -echo "✅ .env 文件存在" - -# 加载.env文件 -set -a -source .env 2>/dev/null -set +a - -# 检查必需的环境变量 -missing_vars=() -for var in "${REQUIRED_VARS[@]}"; do - if [ -z "${!var}" ]; then - missing_vars+=("$var") - fi -done - -if [ ${#missing_vars[@]} -gt 0 ]; then - echo "❌ 缺少以下必需的环境变量:" - for var in "${missing_vars[@]}"; do - echo " - $var" - done - echo "" - echo "💡 请在 .env 文件中设置这些变量" - exit 1 -fi - -echo "✅ 所有必需的环境变量都已设置" - -# 检查关键配置的合理性 -echo "" -echo "📋 当前配置概览:" -echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:${DATABASE_PORT:-5432}/$DATABASE_NAME" -echo " Redis: $REDIS_HOST:${REDIS_PORT:-6379}" -echo " RustFS: $RUSTFS_ENDPOINT" -echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS" -echo " JWT密钥长度: ${#JWT_SECRET} 字符" - -# 检查JWT密钥长度 -if [ ${#JWT_SECRET} -lt 32 ]; then - echo "⚠️ JWT密钥过短,建议使用至少32字符的随机字符串" -fi - -# 检查默认密码 -if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then - echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改" -fi - -if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ] || [ "$DATABASE_PASSWORD" = "carrotskin123" ]; then - echo "⚠️ 使用的是默认数据库密码,生产环境中请更改" -fi - -if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ] || [ "$RUSTFS_ACCESS_KEY" = "rustfsadmin" ]; then - echo "⚠️ 使用的是默认RustFS凭证,生产环境中请更改" -fi - -echo "" -echo "🎉 环境变量检查完成!" diff --git a/scripts/generate_test_account.py b/scripts/generate_test_account.py index 32199d0..9e5d92b 100644 --- a/scripts/generate_test_account.py +++ b/scripts/generate_test_account.py @@ -257,33 +257,222 @@ curl -X GET http://localhost:8080/api/v1/profile/{profile_info['profile_uuid']} return output +def set_user_role(admin_token, user_id, role): + """设置用户角色""" + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json" + } + + try: + response = requests.put( + f"{BASE_URL}/admin/users/role", + json={"user_id": user_id, "role": role}, + headers=headers, + timeout=10 + ) + response.raise_for_status() + result = response.json() + + if result.get("code") == 200: + print_success(f"用户角色设置为: {role}") + return True + else: + print_error(f"设置角色失败: {result.get('message', '未知错误')}") + return False + except requests.exceptions.RequestException as e: + print_error(f"设置角色失败: {str(e)}") + return False + + +def login_user(username, password): + """登录用户""" + try: + response = requests.post( + f"{BASE_URL}/auth/login", + json={"username": username, "password": password}, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + response.raise_for_status() + result = response.json() + + if result.get("code") == 200: + return result["data"]["token"] + return None + except requests.exceptions.RequestException: + return None + + +def create_admin_user(): + """创建管理员用户""" + print_step("创建管理员用户") + + random_num = random.randint(10000, 99999) + username = f"admin{random_num}" + email = f"admin{random_num}@example.com" + login_password = "admin123456" + verification_code = "123456" + + print_info(f"用户名: {username}") + print_info(f"邮箱: {email}") + print_info(f"密码: {login_password}") + + register_data = { + "username": username, + "email": email, + "password": login_password, + "verification_code": verification_code + } + + try: + response = requests.post( + f"{BASE_URL}/auth/register", + json=register_data, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + response.raise_for_status() + result = response.json() + + if result.get("code") == 200: + jwt_token = result["data"]["token"] + user_id = result["data"]["user_info"]["id"] + + print_success("用户注册成功!") + print_info(f"用户ID: {user_id}") + + # 使用默认管理员账户提升权限 + print_info("尝试使用默认管理员账户提升权限...") + default_admin_token = login_user("admin", "admin123456") + + if default_admin_token: + if set_user_role(default_admin_token, user_id, "admin"): + print_success("管理员权限设置成功!") + return { + "username": username, + "email": email, + "password": login_password, + "jwt_token": jwt_token, + "user_id": user_id, + "role": "admin" + } + + print_error("无法提升权限,请手动设置") + return { + "username": username, + "email": email, + "password": login_password, + "jwt_token": jwt_token, + "user_id": user_id, + "role": "user" + } + else: + print_error(f"注册失败: {result.get('message', '未知错误')}") + return None + except requests.exceptions.RequestException as e: + print_error(f"注册失败: {str(e)}") + return None + + +def generate_admin_output(admin_info): + """生成管理员账户输出信息""" + output = f"""======================================== + CarrotSkin 管理员账户信息 +======================================== + +=== 账户信息 === +用户名: {admin_info['username']} +邮箱: {admin_info['email']} +密码: {admin_info['password']} +用户ID: {admin_info['user_id']} +角色: {admin_info['role']} + +=== JWT Token (API认证) === +Token: {admin_info['jwt_token']} + +=== 管理员API示例 === + +# 1. 获取用户列表 +curl -X GET "{BASE_URL}/admin/users" \\ + -H "Authorization: Bearer {admin_info['jwt_token']}" + +# 2. 设置用户角色为管理员 +curl -X PUT "{BASE_URL}/admin/users/role" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer {admin_info['jwt_token']}" \\ + -d '{{"user_id": 1, "role": "admin"}}' + +# 3. 获取材质列表(审核) +curl -X GET "{BASE_URL}/admin/textures" \\ + -H "Authorization: Bearer {admin_info['jwt_token']}" + +# 4. 删除材质 +curl -X DELETE "{BASE_URL}/admin/textures/1" \\ + -H "Authorization: Bearer {admin_info['jwt_token']}" + +======================================== +""" + return output + + def main(): """主函数""" print_header("CarrotSkin 测试账户生成器") - # 步骤1: 注册用户 - user_info = register_user() + # 选择模式 + print("请选择操作:") + print(" 1. 创建普通测试用户") + print(" 2. 创建管理员用户") + print(" 3. 创建两者") - # 步骤2: 创建角色 - profile_info = create_profile(user_info["jwt_token"]) + choice = input("\n请输入选项 (1/2/3) [默认: 1]: ").strip() or "1" - # 步骤3: 重置Yggdrasil密码 - yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"]) + if choice in ["1", "3"]: + # 创建普通用户 + print_header("创建普通测试用户") + + # 步骤1: 注册用户 + user_info = register_user() + + # 步骤2: 创建角色 + profile_info = create_profile(user_info["jwt_token"]) + + # 步骤3: 重置Yggdrasil密码 + yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"]) + + # 输出信息 + print_header("普通用户信息汇总") + output = generate_output(user_info, profile_info, yggdrasil_password) + print(output) + + # 保存到文件 + output_file = f"test_account_{user_info['username']}.txt" + try: + with open(output_file, "w", encoding="utf-8") as f: + f.write(output) + print_success(f"信息已保存到文件: {output_file}") + except Exception as e: + print_error(f"保存文件失败: {str(e)}") - # 步骤4: 输出所有信息 - print_header("测试账户信息汇总") - - output = generate_output(user_info, profile_info, yggdrasil_password) - print(output) - - # 保存到文件 - output_file = f"test_account_{user_info['username']}.txt" - try: - with open(output_file, "w", encoding="utf-8") as f: - f.write(output) - print_success(f"信息已保存到文件: {output_file}") - except Exception as e: - print_error(f"保存文件失败: {str(e)}") + if choice in ["2", "3"]: + # 创建管理员用户 + print_header("创建管理员用户") + admin_info = create_admin_user() + + if admin_info: + print_header("管理员账户信息汇总") + admin_output = generate_admin_output(admin_info) + print(admin_output) + + # 保存到文件 + admin_output_file = f"admin_account_{admin_info['username']}.txt" + try: + with open(admin_output_file, "w", encoding="utf-8") as f: + f.write(admin_output) + print_success(f"管理员信息已保存到文件: {admin_output_file}") + except Exception as e: + print_error(f"保存文件失败: {str(e)}") print_header("测试完成!")