删除服务端材质渲染功能及system_config表,转为环境变量配置,初步配置管理员功能
This commit is contained in:
@@ -2,6 +2,20 @@
|
|||||||
# 复制此文件为 .env 后修改配置值
|
# 复制此文件为 .env 后修改配置值
|
||||||
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
|
# 此文件用于 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
|
APP_PORT=8080
|
||||||
|
|||||||
20
.env.example
20
.env.example
@@ -1,6 +1,26 @@
|
|||||||
# CarrotSkin 环境配置文件示例
|
# CarrotSkin 环境配置文件示例
|
||||||
# 复制此文件为 .env 并修改相应的配置值
|
# 复制此文件为 .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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -91,12 +91,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
emailServiceInstance := email.MustGetService()
|
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(
|
c := container.NewContainer(
|
||||||
database.MustGetDB(),
|
database.MustGetDB(),
|
||||||
redis.MustGetClient(),
|
redis.MustGetClient(),
|
||||||
loggerInstance,
|
loggerInstance,
|
||||||
auth.MustGetJWTService(),
|
auth.MustGetJWTService(),
|
||||||
|
casbinService,
|
||||||
storageClient,
|
storageClient,
|
||||||
emailServiceInstance,
|
emailServiceInstance,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ g = _, _
|
|||||||
e = some(where (p.eft == allow))
|
e = some(where (p.eft == allow))
|
||||||
|
|
||||||
[matchers]
|
[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)
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8080}:8080"
|
- "${APP_PORT:-8080}:8080"
|
||||||
environment:
|
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_PORT=:8080
|
||||||
- SERVER_MODE=${SERVER_MODE:-release}
|
- SERVER_MODE=${SERVER_MODE:-release}
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -5,7 +5,6 @@ go 1.24.0
|
|||||||
toolchain go1.24.2
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/chai2010/webp v1.4.0
|
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -27,9 +26,15 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // 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/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.4.0 // 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/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-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.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-openapi/swag/yamlutils v0.25.4 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.0 // 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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
github.com/microsoft/go-mssqldb v1.7.2 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.57.1 // 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
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
golang.org/x/image v0.33.0 // indirect
|
golang.org/x/image v0.33.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
gorm.io/driver/mysql v1.6.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 (
|
require (
|
||||||
|
|||||||
114
go.sum
114
go.sum
@@ -1,7 +1,14 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
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 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 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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-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 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
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 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
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=
|
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-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 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang-sql/civil v0.0.0-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 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
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 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
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/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
|
github.com/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
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-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-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 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
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/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.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.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 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
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-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-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.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.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.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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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-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.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.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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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-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.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.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.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.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.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.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.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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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-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.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.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.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 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
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=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
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/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 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
|
||||||
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
|
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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
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=
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Container struct {
|
|||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
JWT *auth.JWTService
|
JWT *auth.JWTService
|
||||||
|
Casbin *auth.CasbinService
|
||||||
Storage *storage.StorageClient
|
Storage *storage.StorageClient
|
||||||
CacheManager *database.CacheManager
|
CacheManager *database.CacheManager
|
||||||
|
|
||||||
@@ -31,20 +32,18 @@ type Container struct {
|
|||||||
TextureRepo repository.TextureRepository
|
TextureRepo repository.TextureRepository
|
||||||
TokenRepo repository.TokenRepository
|
TokenRepo repository.TokenRepository
|
||||||
ClientRepo repository.ClientRepository
|
ClientRepo repository.ClientRepository
|
||||||
ConfigRepo repository.SystemConfigRepository
|
|
||||||
YggdrasilRepo repository.YggdrasilRepository
|
YggdrasilRepo repository.YggdrasilRepository
|
||||||
|
|
||||||
// Service层
|
// Service层
|
||||||
UserService service.UserService
|
UserService service.UserService
|
||||||
ProfileService service.ProfileService
|
ProfileService service.ProfileService
|
||||||
TextureService service.TextureService
|
TextureService service.TextureService
|
||||||
TokenService service.TokenService
|
TokenService service.TokenService
|
||||||
YggdrasilService service.YggdrasilService
|
YggdrasilService service.YggdrasilService
|
||||||
VerificationService service.VerificationService
|
VerificationService service.VerificationService
|
||||||
SecurityService service.SecurityService
|
SecurityService service.SecurityService
|
||||||
CaptchaService service.CaptchaService
|
CaptchaService service.CaptchaService
|
||||||
SignatureService *service.SignatureService
|
SignatureService *service.SignatureService
|
||||||
TextureRenderService service.TextureRenderService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContainer 创建依赖容器
|
// NewContainer 创建依赖容器
|
||||||
@@ -53,6 +52,7 @@ func NewContainer(
|
|||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
jwtService *auth.JWTService,
|
jwtService *auth.JWTService,
|
||||||
|
casbinService *auth.CasbinService,
|
||||||
storageClient *storage.StorageClient,
|
storageClient *storage.StorageClient,
|
||||||
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
|
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
|
||||||
) *Container {
|
) *Container {
|
||||||
@@ -68,6 +68,7 @@ func NewContainer(
|
|||||||
Redis: redisClient,
|
Redis: redisClient,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
JWT: jwtService,
|
JWT: jwtService,
|
||||||
|
Casbin: casbinService,
|
||||||
Storage: storageClient,
|
Storage: storageClient,
|
||||||
CacheManager: cacheManager,
|
CacheManager: cacheManager,
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,6 @@ func NewContainer(
|
|||||||
c.TextureRepo = repository.NewTextureRepository(db)
|
c.TextureRepo = repository.NewTextureRepository(db)
|
||||||
c.TokenRepo = repository.NewTokenRepository(db)
|
c.TokenRepo = repository.NewTokenRepository(db)
|
||||||
c.ClientRepo = repository.NewClientRepository(db)
|
c.ClientRepo = repository.NewClientRepository(db)
|
||||||
c.ConfigRepo = repository.NewSystemConfigRepository(db)
|
|
||||||
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
||||||
|
|
||||||
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
||||||
@@ -86,10 +86,9 @@ func NewContainer(
|
|||||||
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
||||||
|
|
||||||
// 初始化Service(注入缓存管理器)
|
// 初始化Service(注入缓存管理器)
|
||||||
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
||||||
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
||||||
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
||||||
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
|
|
||||||
|
|
||||||
// 获取Yggdrasil私钥并创建JWT服务(TokenService需要)
|
// 获取Yggdrasil私钥并创建JWT服务(TokenService需要)
|
||||||
// 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT
|
// 注意:这里仍然需要预先初始化,因为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 设置用户服务
|
// WithUserService 设置用户服务
|
||||||
func WithUserService(svc service.UserService) Option {
|
func WithUserService(svc service.UserService) Option {
|
||||||
return func(c *Container) {
|
return func(c *Container) {
|
||||||
|
|||||||
366
internal/handler/admin_handler.go
Normal file
366
internal/handler/admin_handler.go
Normal file
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"carrotskin/internal/middleware"
|
"carrotskin/internal/middleware"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
|
"carrotskin/pkg/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
swaggerFiles "github.com/swaggo/files"
|
||||||
@@ -20,6 +21,7 @@ type Handlers struct {
|
|||||||
Captcha *CaptchaHandler
|
Captcha *CaptchaHandler
|
||||||
Yggdrasil *YggdrasilHandler
|
Yggdrasil *YggdrasilHandler
|
||||||
CustomSkin *CustomSkinHandler
|
CustomSkin *CustomSkinHandler
|
||||||
|
Admin *AdminHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandlers 创建所有Handler实例
|
// NewHandlers 创建所有Handler实例
|
||||||
@@ -32,6 +34,7 @@ func NewHandlers(c *container.Container) *Handlers {
|
|||||||
Captcha: NewCaptchaHandler(c),
|
Captcha: NewCaptchaHandler(c),
|
||||||
Yggdrasil: NewYggdrasilHandler(c),
|
Yggdrasil: NewYggdrasilHandler(c),
|
||||||
CustomSkin: NewCustomSkinHandler(c),
|
CustomSkin: NewCustomSkinHandler(c),
|
||||||
|
Admin: NewAdminHandler(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +71,13 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
|||||||
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
|
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
|
||||||
|
|
||||||
// 系统路由
|
// 系统路由
|
||||||
registerSystemRoutes(v1)
|
registerSystemRoutes(v1, c)
|
||||||
|
|
||||||
// CustomSkinAPI 路由
|
// CustomSkinAPI 路由
|
||||||
registerCustomSkinRoutes(v1, h.CustomSkin)
|
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("", h.Search)
|
||||||
textureGroup.GET("/:id", h.Get)
|
textureGroup.GET("/:id", h.Get)
|
||||||
textureGroup.GET("/:id/render", h.RenderTexture) // type/front/back/full/head/isometric
|
|
||||||
textureGroup.GET("/:id/avatar", h.RenderAvatar) // mode=2d/3d
|
|
||||||
textureGroup.GET("/:id/cape", h.RenderCape)
|
|
||||||
textureGroup.GET("/:id/preview", h.RenderPreview) // 自动根据类型预览
|
|
||||||
|
|
||||||
// 需要认证的路由
|
// 需要认证的路由
|
||||||
textureAuth := textureGroup.Group("")
|
textureAuth := textureGroup.Group("")
|
||||||
@@ -192,17 +194,46 @@ func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// registerSystemRoutes 注册系统路由
|
// registerSystemRoutes 注册系统路由
|
||||||
func registerSystemRoutes(v1 *gin.RouterGroup) {
|
func registerSystemRoutes(v1 *gin.RouterGroup, c *container.Container) {
|
||||||
system := v1.Group("/system")
|
system := v1.Group("/system")
|
||||||
{
|
{
|
||||||
system.GET("/config", func(c *gin.Context) {
|
// 公开配置(无需认证)
|
||||||
// TODO: 实现从数据库读取系统配置
|
system.GET("/config", func(ctx *gin.Context) {
|
||||||
c.JSON(200, model.NewSuccessResponse(gin.H{
|
cfg, _ := config.GetConfig()
|
||||||
"site_name": "CarrotSkin",
|
ctx.JSON(200, model.NewSuccessResponse(gin.H{
|
||||||
"site_description": "A Minecraft Skin Station",
|
"site_name": cfg.Site.Name,
|
||||||
"registration_enabled": true,
|
"site_description": cfg.Site.Description,
|
||||||
"max_textures_per_user": 100,
|
"registration_enabled": cfg.Site.RegistrationEnabled,
|
||||||
"max_profiles_per_user": 5,
|
"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,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"carrotskin/internal/service"
|
|
||||||
"carrotskin/internal/types"
|
"carrotskin/internal/types"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -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 更新材质
|
// Update 更新材质
|
||||||
func (h *TextureHandler) Update(c *gin.Context) {
|
func (h *TextureHandler) Update(c *gin.Context) {
|
||||||
userID, ok := GetUserIDFromContext(c)
|
userID, ok := GetUserIDFromContext(c)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
|
|||||||
// 将用户信息存储到上下文中
|
// 将用户信息存储到上下文中
|
||||||
c.Set("user_id", claims.UserID)
|
c.Set("user_id", claims.UserID)
|
||||||
c.Set("username", claims.Username)
|
c.Set("username", claims.Username)
|
||||||
c.Set("role", claims.Role)
|
c.Set("user_role", claims.Role)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
@@ -69,7 +69,7 @@ func OptionalAuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
c.Set("user_id", claims.UserID)
|
c.Set("user_id", claims.UserID)
|
||||||
c.Set("username", claims.Username)
|
c.Set("username", claims.Username)
|
||||||
c.Set("role", claims.Role)
|
c.Set("user_role", claims.Role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
internal/middleware/casbin.go
Normal file
109
internal/middleware/casbin.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -78,15 +78,6 @@ type TokenRepository interface {
|
|||||||
BatchDelete(ctx context.Context, accessTokens []string) (int64, error)
|
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仓储接口
|
// YggdrasilRepository Yggdrasil仓储接口
|
||||||
type YggdrasilRepository interface {
|
type YggdrasilRepository interface {
|
||||||
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -136,69 +136,6 @@ type SecurityService interface {
|
|||||||
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextureRenderService 纹理渲染服务接口
|
|
||||||
type TextureRenderService interface {
|
|
||||||
// RenderTexture 渲染纹理为预览图
|
|
||||||
RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error)
|
|
||||||
// RenderTextureFromData 从原始数据渲染纹理
|
|
||||||
RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error)
|
|
||||||
// GetRenderURL 获取渲染图的URL
|
|
||||||
GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string
|
|
||||||
// DeleteRenderCache 删除渲染缓存
|
|
||||||
DeleteRenderCache(ctx context.Context, textureID int64) error
|
|
||||||
// RenderAvatar 渲染头像(支持2D/3D模式)
|
|
||||||
RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error)
|
|
||||||
// RenderCape 渲染披风
|
|
||||||
RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
|
|
||||||
// RenderPreview 渲染预览图(类似Blessing Skin的preview功能)
|
|
||||||
RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderType 渲染类型
|
|
||||||
type RenderType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
RenderTypeFront RenderType = "front" // 正面
|
|
||||||
RenderTypeBack RenderType = "back" // 背面
|
|
||||||
RenderTypeFull RenderType = "full" // 全身
|
|
||||||
RenderTypeHead RenderType = "head" // 头像
|
|
||||||
RenderTypeIsometric RenderType = "isometric" // 等距视图
|
|
||||||
)
|
|
||||||
|
|
||||||
// ImageFormat 输出格式
|
|
||||||
type ImageFormat string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ImageFormatPNG ImageFormat = "png"
|
|
||||||
ImageFormatWEBP ImageFormat = "webp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AvatarMode 头像模式
|
|
||||||
type AvatarMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AvatarMode2D AvatarMode = "2d" // 2D头像
|
|
||||||
AvatarMode3D AvatarMode = "3d" // 3D头像
|
|
||||||
)
|
|
||||||
|
|
||||||
// TextureType 纹理类型
|
|
||||||
type TextureType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TextureTypeSteve TextureType = "steve" // Steve皮肤
|
|
||||||
TextureTypeAlex TextureType = "alex" // Alex皮肤
|
|
||||||
TextureTypeCape TextureType = "cape" // 披风
|
|
||||||
)
|
|
||||||
|
|
||||||
// RenderResult 渲染结果(附带缓存/HTTP头信息)
|
|
||||||
type RenderResult struct {
|
|
||||||
URL string
|
|
||||||
ContentType string
|
|
||||||
ETag string
|
|
||||||
LastModified time.Time
|
|
||||||
Size int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Services 服务集合
|
// Services 服务集合
|
||||||
type Services struct {
|
type Services struct {
|
||||||
User UserService
|
User UserService
|
||||||
|
|||||||
@@ -565,53 +565,6 @@ func (m *MockTokenRepository) BatchDelete(ctx context.Context, accessTokens []st
|
|||||||
return count, nil
|
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
|
// Service Mocks
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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("多边形应该在图像上绘制了像素")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,6 @@ import (
|
|||||||
// userService UserService的实现
|
// userService UserService的实现
|
||||||
type userService struct {
|
type userService struct {
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
configRepo repository.SystemConfigRepository
|
|
||||||
jwtService *auth.JWTService
|
jwtService *auth.JWTService
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
cache *database.CacheManager
|
cache *database.CacheManager
|
||||||
@@ -40,7 +39,6 @@ type userService struct {
|
|||||||
// NewUserService 创建UserService实例
|
// NewUserService 创建UserService实例
|
||||||
func NewUserService(
|
func NewUserService(
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
configRepo repository.SystemConfigRepository,
|
|
||||||
jwtService *auth.JWTService,
|
jwtService *auth.JWTService,
|
||||||
redisClient *redis.Client,
|
redisClient *redis.Client,
|
||||||
cacheManager *database.CacheManager,
|
cacheManager *database.CacheManager,
|
||||||
@@ -51,7 +49,6 @@ func NewUserService(
|
|||||||
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
|
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
|
||||||
return &userService{
|
return &userService{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
configRepo: configRepo,
|
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
cache: cacheManager,
|
cache: cacheManager,
|
||||||
@@ -417,39 +414,29 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) GetMaxProfilesPerUser() int {
|
func (s *userService) GetMaxProfilesPerUser() int {
|
||||||
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
cfg, err := config.GetConfig()
|
||||||
if err != nil || config == nil {
|
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
|
||||||
return 5
|
return 5
|
||||||
}
|
}
|
||||||
var value int
|
return cfg.Site.MaxProfilesPerUser
|
||||||
fmt.Sscanf(config.Value, "%d", &value)
|
|
||||||
if value <= 0 {
|
|
||||||
return 5
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) GetMaxTexturesPerUser() int {
|
func (s *userService) GetMaxTexturesPerUser() int {
|
||||||
config, err := s.configRepo.GetByKey(context.Background(), "max_textures_per_user")
|
cfg, err := config.GetConfig()
|
||||||
if err != nil || config == nil {
|
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
|
||||||
return 50
|
return 50
|
||||||
}
|
}
|
||||||
var value int
|
return cfg.Site.MaxTexturesPerUser
|
||||||
fmt.Sscanf(config.Value, "%d", &value)
|
|
||||||
if value <= 0 {
|
|
||||||
return 50
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 私有辅助方法
|
// 私有辅助方法
|
||||||
|
|
||||||
func (s *userService) getDefaultAvatar() string {
|
func (s *userService) getDefaultAvatar() string {
|
||||||
config, err := s.configRepo.GetByKey(context.Background(), "default_avatar")
|
cfg, err := config.GetConfig()
|
||||||
if err != nil || config == nil || config.Value == "" {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return config.Value
|
return cfg.Site.DefaultAvatar
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
|
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import (
|
|||||||
func TestUserServiceImpl_Register(t *testing.T) {
|
func TestUserServiceImpl_Register(t *testing.T) {
|
||||||
// 准备依赖
|
// 准备依赖
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
// 初始化Service
|
// 初始化Service
|
||||||
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
// 注意:redisClient 和 storageClient 传入 nil,因为 Register 方法中没有使用它们
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -114,7 +113,6 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
|||||||
func TestUserServiceImpl_Login(t *testing.T) {
|
func TestUserServiceImpl_Login(t *testing.T) {
|
||||||
// 准备依赖
|
// 准备依赖
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
@@ -130,7 +128,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), testUser)
|
_ = userRepo.Create(context.Background(), testUser)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -197,7 +195,6 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
|||||||
// TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar
|
// TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar
|
||||||
func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
@@ -211,7 +208,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -246,7 +243,6 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
|||||||
// TestUserServiceImpl_ChangePassword 测试 ChangePassword
|
// TestUserServiceImpl_ChangePassword 测试 ChangePassword
|
||||||
func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
@@ -259,7 +255,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -282,7 +278,6 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
|||||||
// TestUserServiceImpl_ResetPassword 测试 ResetPassword
|
// TestUserServiceImpl_ResetPassword 测试 ResetPassword
|
||||||
func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
@@ -294,7 +289,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user)
|
_ = userRepo.Create(context.Background(), user)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -312,7 +307,6 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
|||||||
// TestUserServiceImpl_ChangeEmail 测试 ChangeEmail
|
// TestUserServiceImpl_ChangeEmail 测试 ChangeEmail
|
||||||
func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
@@ -322,7 +316,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
|||||||
_ = userRepo.Create(context.Background(), user2)
|
_ = userRepo.Create(context.Background(), user2)
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -340,12 +334,11 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
|||||||
// TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL
|
// TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL
|
||||||
func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
|
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -373,30 +366,19 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser
|
// TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser
|
||||||
|
// 现在配置从环境变量读取,测试默认值
|
||||||
func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
||||||
userRepo := NewMockUserRepository()
|
userRepo := NewMockUserRepository()
|
||||||
configRepo := NewMockSystemConfigRepository()
|
|
||||||
jwtService := auth.NewJWTService("secret", 1)
|
jwtService := auth.NewJWTService("secret", 1)
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
|
||||||
// 未配置时走默认值
|
// 未配置时走默认值
|
||||||
cacheManager := NewMockCacheManager()
|
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 {
|
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||||
}
|
}
|
||||||
if got := userService.GetMaxTexturesPerUser(); got != 50 {
|
if got := userService.GetMaxTexturesPerUser(); got != 50 {
|
||||||
t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,12 +173,3 @@ type SystemConfigResponse struct {
|
|||||||
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
|
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
|
||||||
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
|
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"`
|
|
||||||
}
|
|
||||||
|
|||||||
124
pkg/auth/casbin.go
Normal file
124
pkg/auth/casbin.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
// Config 应用配置结构体
|
// Config 应用配置结构体
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Environment string `mapstructure:"environment"`
|
Environment string `mapstructure:"environment"`
|
||||||
|
Site SiteConfig `mapstructure:"site"`
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
Redis RedisConfig `mapstructure:"redis"`
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
@@ -25,6 +26,18 @@ type Config struct {
|
|||||||
Security SecurityConfig `mapstructure:"security"`
|
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 服务器配置
|
// ServerConfig 服务器配置
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
@@ -201,6 +214,16 @@ func setDefaults() {
|
|||||||
// 安全默认配置
|
// 安全默认配置
|
||||||
viper.SetDefault("security.allowed_origins", []string{"*"})
|
viper.SetDefault("security.allowed_origins", []string{"*"})
|
||||||
viper.SetDefault("security.allowed_domains", []string{"localhost", "127.0.0.1"})
|
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 设置环境变量映射
|
// setupEnvMappings 设置环境变量映射
|
||||||
@@ -262,6 +285,16 @@ func setupEnvMappings() {
|
|||||||
viper.BindEnv("email.username", "EMAIL_USERNAME")
|
viper.BindEnv("email.username", "EMAIL_USERNAME")
|
||||||
viper.BindEnv("email.password", "EMAIL_PASSWORD")
|
viper.BindEnv("email.password", "EMAIL_PASSWORD")
|
||||||
viper.BindEnv("email.from_name", "EMAIL_FROM_NAME")
|
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 从环境变量中覆盖配置
|
// overrideFromEnv 从环境变量中覆盖配置
|
||||||
|
|||||||
@@ -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)
|
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 构建令牌缓存键
|
// Token 构建令牌缓存键
|
||||||
func (b *CacheKeyBuilder) Token(accessToken string) string {
|
func (b *CacheKeyBuilder) Token(accessToken string) string {
|
||||||
return fmt.Sprintf("%stoken:%s", b.prefix, accessToken)
|
return fmt.Sprintf("%stoken:%s", b.prefix, accessToken)
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ func AutoMigrate(logger *zap.Logger) error {
|
|||||||
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
||||||
&model.Yggdrasil{},
|
&model.Yggdrasil{},
|
||||||
|
|
||||||
// 系统配置表
|
|
||||||
&model.SystemConfig{},
|
|
||||||
|
|
||||||
// 审计日志表
|
// 审计日志表
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
|
|
||||||
|
|||||||
@@ -12,36 +12,41 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultAdminUsername = "admin"
|
defaultAdminUsername = "admin"
|
||||||
defaultAdminEmail = "admin@example.com"
|
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权限规则
|
// defaultCasbinRules 默认Casbin权限规则
|
||||||
|
// 规则格式: {PType: "p", V0: "角色", V1: "资源", V2: "操作"}
|
||||||
|
// PType "p" 表示策略规则,"g" 表示角色继承
|
||||||
var defaultCasbinRules = []model.CasbinRule{
|
var defaultCasbinRules = []model.CasbinRule{
|
||||||
// 管理员拥有所有权限
|
// ==================== 管理员权限 ====================
|
||||||
|
// 管理员拥有所有权限(通配符)
|
||||||
{PType: "p", V0: "admin", V1: "*", V2: "*"},
|
{PType: "p", V0: "admin", V1: "*", V2: "*"},
|
||||||
// 普通用户权限
|
|
||||||
{PType: "p", V0: "user", V1: "texture", V2: "create"},
|
// ==================== 普通用户权限 ====================
|
||||||
{PType: "p", V0: "user", V1: "texture", V2: "read"},
|
// --- 用户资源 (user) ---
|
||||||
{PType: "p", V0: "user", V1: "texture", V2: "update_own"},
|
{PType: "p", V0: "user", V1: "user", V2: "read_own"}, // 查看自己的信息
|
||||||
{PType: "p", V0: "user", V1: "texture", V2: "delete_own"},
|
{PType: "p", V0: "user", V1: "user", V2: "update_own"}, // 更新自己的信息
|
||||||
{PType: "p", V0: "user", V1: "profile", V2: "create"},
|
|
||||||
{PType: "p", V0: "user", V1: "profile", V2: "read"},
|
// --- 材质资源 (texture) ---
|
||||||
{PType: "p", V0: "user", V1: "profile", V2: "update_own"},
|
{PType: "p", V0: "user", V1: "texture", V2: "read"}, // 查看材质(公开)
|
||||||
{PType: "p", V0: "user", V1: "profile", V2: "delete_own"},
|
{PType: "p", V0: "user", V1: "texture", V2: "create"}, // 上传材质
|
||||||
{PType: "p", V0: "user", V1: "user", V2: "update_own"},
|
{PType: "p", V0: "user", V1: "texture", V2: "update_own"}, // 更新自己的材质
|
||||||
// 角色继承:admin 继承 user 的所有权限
|
{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"},
|
{PType: "g", V0: "admin", V1: "user"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +64,6 @@ func Seed(logger *zap.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化系统配置
|
|
||||||
if err := seedSystemConfigs(db, logger); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化Casbin权限规则
|
// 初始化Casbin权限规则
|
||||||
if err := seedCasbinRules(db, logger); err != nil {
|
if err := seedCasbinRules(db, logger); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -119,23 +119,6 @@ func seedAdminUser(db *gorm.DB, logger *zap.Logger) error {
|
|||||||
return nil
|
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权限规则
|
// seedCasbinRules 初始化Casbin权限规则
|
||||||
func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
|
func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
|
||||||
for _, rule := range defaultCasbinRules {
|
for _, rule := range defaultCasbinRules {
|
||||||
@@ -153,4 +136,3 @@ func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "🎉 环境变量检查完成!"
|
|
||||||
@@ -257,33 +257,222 @@ curl -X GET http://localhost:8080/api/v1/profile/{profile_info['profile_uuid']}
|
|||||||
return output
|
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():
|
def main():
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
print_header("CarrotSkin 测试账户生成器")
|
print_header("CarrotSkin 测试账户生成器")
|
||||||
|
|
||||||
# 步骤1: 注册用户
|
# 选择模式
|
||||||
user_info = register_user()
|
print("请选择操作:")
|
||||||
|
print(" 1. 创建普通测试用户")
|
||||||
|
print(" 2. 创建管理员用户")
|
||||||
|
print(" 3. 创建两者")
|
||||||
|
|
||||||
# 步骤2: 创建角色
|
choice = input("\n请输入选项 (1/2/3) [默认: 1]: ").strip() or "1"
|
||||||
profile_info = create_profile(user_info["jwt_token"])
|
|
||||||
|
|
||||||
# 步骤3: 重置Yggdrasil密码
|
if choice in ["1", "3"]:
|
||||||
yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"])
|
# 创建普通用户
|
||||||
|
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: 输出所有信息
|
if choice in ["2", "3"]:
|
||||||
print_header("测试账户信息汇总")
|
# 创建管理员用户
|
||||||
|
print_header("创建管理员用户")
|
||||||
output = generate_output(user_info, profile_info, yggdrasil_password)
|
admin_info = create_admin_user()
|
||||||
print(output)
|
|
||||||
|
if admin_info:
|
||||||
# 保存到文件
|
print_header("管理员账户信息汇总")
|
||||||
output_file = f"test_account_{user_info['username']}.txt"
|
admin_output = generate_admin_output(admin_info)
|
||||||
try:
|
print(admin_output)
|
||||||
with open(output_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write(output)
|
# 保存到文件
|
||||||
print_success(f"信息已保存到文件: {output_file}")
|
admin_output_file = f"admin_account_{admin_info['username']}.txt"
|
||||||
except Exception as e:
|
try:
|
||||||
print_error(f"保存文件失败: {str(e)}")
|
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("测试完成!")
|
print_header("测试完成!")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user