4 Commits

Author SHA1 Message Date
399e6f096f 暂存服务端渲染功能,材质渲染计划迁移至前端 2025-12-08 17:40:28 +08:00
63ca7eff0d 统一文件上传方式为直接上传,更新环境变量示例 2025-12-08 15:40:28 +08:00
aa75691c49 完善服务端材质渲染(未测试),删除profile表中不必要的isActive字段及相关接口 2025-12-07 20:51:20 +08:00
lan
a51535a465 feat: Add texture rendering endpoints and service methods
- Introduced new API endpoints for rendering textures, avatars, capes, and previews, enhancing the texture handling capabilities.
- Implemented corresponding service methods in the TextureHandler to process rendering requests and return appropriate responses.
- Updated the TextureRenderService interface to include methods for rendering textures, avatars, and capes, along with their respective parameters.
- Enhanced error handling for invalid texture IDs and added support for different rendering types and formats.
- Updated go.mod to include the webp library for image processing.
2025-12-07 10:10:28 +08:00
53 changed files with 3819 additions and 1376 deletions

View File

@@ -1,18 +1,16 @@
# ==================== CarrotSkin Docker 环境配置示例 ==================== # ==================== CarrotSkin Docker 环境配置示例 ====================
# 复制此文件为 .env 后修改配置值 # 复制此文件为 .env 后修改配置值
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
# ==================== 服务配置 ==================== # ==================== 服务配置 ====================
# 应用端口 # 应用对外端口
APP_PORT=8080 APP_PORT=8080
# 运行模式: debug, release, test # 运行模式: debug, release, test
SERVER_MODE=release SERVER_MODE=release
# API 根路径 (用于反向代理,如 /api)
SERVER_BASE_PATH=
# 公开访问地址 (用于生成回调URL、邮件链接等)
PUBLIC_URL=http://localhost:8080
# ==================== 数据库配置 ==================== # ==================== 数据库配置 ====================
DB_PASSWORD=carrotskin123 # 数据库密码,生产环境务必修改
DATABASE_PASSWORD=carrotskin123
# ==================== Redis 配置 ==================== # ==================== Redis 配置 ====================
# 留空表示不设置密码 # 留空表示不设置密码
@@ -25,23 +23,26 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production
# ==================== 存储配置 (RustFS S3兼容) ==================== # ==================== 存储配置 (RustFS S3兼容) ====================
# 内部访问地址 (容器间通信) # 内部访问地址 (容器间通信)
RUSTFS_ENDPOINT=rustfs:9000 RUSTFS_ENDPOINT=rustfs:9000
# 公开访问地址 (用于生成文件URL供外部浏览器访问)
# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage
RUSTFS_PUBLIC_URL=http://localhost:9000
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_ACCESS_KEY=rustfsadmin
RUSTFS_SECRET_KEY=rustfsadmin123 RUSTFS_SECRET_KEY=rustfsadmin123
RUSTFS_USE_SSL=false RUSTFS_USE_SSL=false
# 存储桶配置 # 存储桶配置
RUSTFS_BUCKET_TEXTURES=carrotskin RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
RUSTFS_BUCKET_AVATARS=carrotskin RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
# 公开访问地址 (用于生成文件URL供外部浏览器访问) # ==================== 安全配置 ====================
# 示例: # CORS 允许的来源,多个用逗号分隔
# 直接访问: http://localhost:9000 SECURITY_ALLOWED_ORIGINS=*
# 反向代理: https://example.com/storage # 允许的头像/材质URL域名多个用逗号分隔
RUSTFS_PUBLIC_URL=http://localhost:9000 SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# ==================== 邮件配置 (可选) ==================== # ==================== 邮件配置 ====================
SMTP_HOST= EMAIL_ENABLED=false
SMTP_PORT=587 EMAIL_SMTP_HOST=
SMTP_USER= EMAIL_SMTP_PORT=587
SMTP_PASSWORD= EMAIL_USERNAME=
SMTP_FROM= EMAIL_PASSWORD=
EMAIL_FROM_NAME=CarrotSkin

View File

@@ -23,6 +23,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
DATABASE_MAX_IDLE_CONNS=10 DATABASE_MAX_IDLE_CONNS=10
DATABASE_MAX_OPEN_CONNS=100 DATABASE_MAX_OPEN_CONNS=100
DATABASE_CONN_MAX_LIFETIME=1h DATABASE_CONN_MAX_LIFETIME=1h
DATABASE_CONN_MAX_IDLE_TIME=10m
# ============================================================================= # =============================================================================
# Redis配置 # Redis配置
@@ -37,6 +38,7 @@ REDIS_POOL_SIZE=10
# RustFS对象存储配置 (S3兼容) # RustFS对象存储配置 (S3兼容)
# ============================================================================= # =============================================================================
RUSTFS_ENDPOINT=127.0.0.1:9000 RUSTFS_ENDPOINT=127.0.0.1:9000
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=your_access_key RUSTFS_ACCESS_KEY=your_access_key
RUSTFS_SECRET_KEY=your_secret_key RUSTFS_SECRET_KEY=your_secret_key
RUSTFS_USE_SSL=false RUSTFS_USE_SSL=false
@@ -55,26 +57,17 @@ JWT_EXPIRE_HOURS=168
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT=json LOG_FORMAT=json
LOG_OUTPUT=logs/app.log LOG_OUTPUT=logs/app.log
LOG_MAX_SIZE=100
LOG_MAX_BACKUPS=3
LOG_MAX_AGE=28
LOG_COMPRESS=true
# =============================================================================
# 文件上传配置
# =============================================================================
UPLOAD_MAX_SIZE=10485760
UPLOAD_TEXTURE_MAX_SIZE=2097152
UPLOAD_AVATAR_MAX_SIZE=1048576
# ============================================================================= # =============================================================================
# 安全配置 # 安全配置
# ============================================================================= # =============================================================================
MAX_LOGIN_ATTEMPTS=5 # CORS 允许的来源,多个用逗号分隔
LOGIN_LOCK_DURATION=30m SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# ============================================================================= # =============================================================================
# 邮件配置(可选) # 邮件配置
# 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465 # 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465
# ============================================================================= # =============================================================================
EMAIL_ENABLED=false EMAIL_ENABLED=false

4
.gitignore vendored
View File

@@ -23,8 +23,8 @@ dist/
build/ build/
# Compiled binaries # Compiled binaries
/server
server.exe server.exe
main.exe
# IDE files # IDE files
.vscode/ .vscode/
@@ -108,3 +108,5 @@ local/
dev/ dev/
service_coverage service_coverage
.gitignore .gitignore
docs/
blessing skin材质渲染示例/

View File

@@ -1,3 +1,12 @@
// @title CarrotSkin API
// @version 1.0
// @description Minecraft皮肤站后端API
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
package main package main
import ( import (
@@ -22,6 +31,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
_ "carrotskin/docs" // Swagger docs
) )
func main() { func main() {

View File

@@ -13,40 +13,43 @@ services:
- "${APP_PORT:-8080}:8080" - "${APP_PORT:-8080}:8080"
environment: environment:
# 服务器配置 # 服务器配置
- SERVER_PORT=8080 - SERVER_PORT=:8080
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}
- SERVER_BASE_PATH=${SERVER_BASE_PATH:-}
# 公开访问地址 (用于生成回调URL、邮件链接等)
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080}
# 数据库配置 # 数据库配置
- DB_HOST=postgres - DATABASE_DRIVER=postgres
- DB_PORT=5432 - DATABASE_HOST=postgres
- DB_USER=carrotskin - DATABASE_PORT=5432
- DB_PASSWORD=${DB_PASSWORD:-carrotskin123} - DATABASE_USERNAME=carrotskin
- DB_NAME=carrotskin - DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
- DB_SSLMODE=disable - DATABASE_NAME=carrotskin
- DATABASE_SSL_MODE=disable
- DATABASE_TIMEZONE=Asia/Shanghai
# Redis 配置 # Redis 配置
- REDIS_HOST=redis - REDIS_HOST=redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DB=0 - REDIS_DATABASE=0
# JWT 配置 # JWT 配置
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- JWT_EXPIRE_HOURS=24 - JWT_EXPIRE_HOURS=168
# 存储配置 (RustFS S3兼容) # 存储配置 (RustFS S3兼容)
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000} - RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000} - RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false} - RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrotskin} - RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrotskin} - RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
# 邮件配置 (可选) # 安全配置
- SMTP_HOST=${SMTP_HOST:-} - SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*}
- SMTP_PORT=${SMTP_PORT:-587} - SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1}
- SMTP_USER=${SMTP_USER:-} # 邮件配置
- SMTP_PASSWORD=${SMTP_PASSWORD:-} - EMAIL_ENABLED=${EMAIL_ENABLED:-false}
- SMTP_FROM=${SMTP_FROM:-} - EMAIL_SMTP_HOST=${EMAIL_SMTP_HOST:-}
- EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-587}
- EMAIL_USERNAME=${EMAIL_USERNAME:-}
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
- EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-CarrotSkin}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -68,7 +71,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_USER=carrotskin - POSTGRES_USER=carrotskin
- POSTGRES_PASSWORD=${DB_PASSWORD:-carrotskin123} - POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
- POSTGRES_DB=carrotskin - POSTGRES_DB=carrotskin
- PGDATA=/var/lib/postgresql/data/pgdata - PGDATA=/var/lib/postgresql/data/pgdata
volumes: volumes:
@@ -108,7 +111,7 @@ services:
retries: 5 retries: 5
start_period: 5s start_period: 5s
# ==================== RustFS 对象存储 (可选) ==================== # ==================== RustFS 对象存储====================
rustfs: rustfs:
image: ghcr.io/rustfs/rustfs:latest image: ghcr.io/rustfs/rustfs:latest
container_name: carrotskin-rustfs container_name: carrotskin-rustfs
@@ -148,14 +151,19 @@ services:
echo '等待 RustFS 启动...'; echo '等待 RustFS 启动...';
sleep 5; sleep 5;
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY}; mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
mc mb myrustfs/$${RUSTFS_BUCKET} --ignore-existing; echo '创建材质存储桶...';
mc anonymous set download myrustfs/$${RUSTFS_BUCKET}; mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing;
echo '存储桶 $${RUSTFS_BUCKET} 创建完成,已设置公开读取权限'; mc anonymous set download myrustfs/$${RUSTFS_BUCKET_TEXTURES};
echo '创建头像存储桶...';
mc mb myrustfs/$${RUSTFS_BUCKET_AVATARS} --ignore-existing;
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_AVATARS};
echo '存储桶创建完成: $${RUSTFS_BUCKET_TEXTURES}, $${RUSTFS_BUCKET_AVATARS}';
" "
environment: environment:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
- RUSTFS_BUCKET=${RUSTFS_BUCKET_TEXTURES:-carrotskin} - RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
networks: networks:
- carrotskin-network - carrotskin-network
profiles: profiles:

28
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.2 toolchain go1.24.2
require ( require (
github.com/chai2010/webp v1.4.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@@ -12,6 +13,9 @@ require (
github.com/minio/minio-go/v7 v7.0.97 github.com/minio/minio-go/v7 v7.0.97
github.com/redis/go-redis/v9 v9.17.2 github.com/redis/go-redis/v9 v9.17.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/wenlng/go-captcha-assets v1.0.7 github.com/wenlng/go-captcha-assets v1.0.7
github.com/wenlng/go-captcha/v2 v2.0.4 github.com/wenlng/go-captcha/v2 v2.0.4
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
@@ -22,22 +26,32 @@ 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/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/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils 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.18.0 // indirect github.com/goccy/go-yaml v1.19.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.0 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect go.uber.org/mock v0.6.0 // indirect
go.uber.org/mock v0.5.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

66
go.sum
View File

@@ -1,5 +1,7 @@
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/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
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 +14,8 @@ github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2N
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -27,12 +31,41 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 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/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/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -47,8 +80,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
@@ -99,8 +132,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
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 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.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
@@ -116,10 +149,10 @@ 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/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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -150,8 +183,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -163,8 +202,8 @@ github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQv
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
@@ -188,6 +227,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
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-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.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=
@@ -214,6 +254,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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.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/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-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=

View File

@@ -35,16 +35,16 @@ type Container struct {
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
UploadService service.UploadService SecurityService service.SecurityService
SecurityService service.SecurityService CaptchaService service.CaptchaService
CaptchaService service.CaptchaService SignatureService *service.SignatureService
SignatureService *service.SignatureService TextureRenderService service.TextureRenderService
} }
// NewContainer 创建依赖容器 // NewContainer 创建依赖容器
@@ -86,9 +86,10 @@ 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, logger) c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, 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
@@ -105,7 +106,6 @@ func NewContainer(
// 初始化其他服务 // 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient) c.SecurityService = service.NewSecurityService(redisClient)
c.UploadService = service.NewUploadService(storageClient)
c.CaptchaService = service.NewCaptchaService(redisClient, logger) c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化VerificationService需要email.Service // 初始化VerificationService需要email.Service
@@ -249,13 +249,6 @@ func WithVerificationService(svc service.VerificationService) Option {
} }
} }
// WithUploadService 设置上传服务
func WithUploadService(svc service.UploadService) Option {
return func(c *Container) {
c.UploadService = svc
}
}
// WithSecurityService 设置安全服务 // WithSecurityService 设置安全服务
func WithSecurityService(svc service.SecurityService) Option { func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) { return func(c *Container) {

View File

@@ -70,7 +70,6 @@ func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
Name: profile.Name, Name: profile.Name,
SkinID: profile.SkinID, SkinID: profile.SkinID,
CapeID: profile.CapeID, CapeID: profile.CapeID,
IsActive: profile.IsActive,
LastUsedAt: profile.LastUsedAt, LastUsedAt: profile.LastUsedAt,
CreatedAt: profile.CreatedAt, CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt, UpdatedAt: profile.UpdatedAt,
@@ -173,24 +172,24 @@ func RespondWithError(c *gin.Context, err error) {
} }
// 使用errors.Is检查预定义错误 // 使用errors.Is检查预定义错误
if errors.Is(err, errors.ErrUserNotFound) || if errors.Is(err, errors.ErrUserNotFound) ||
errors.Is(err, errors.ErrProfileNotFound) || errors.Is(err, errors.ErrProfileNotFound) ||
errors.Is(err, errors.ErrTextureNotFound) || errors.Is(err, errors.ErrTextureNotFound) ||
errors.Is(err, errors.ErrNotFound) { errors.Is(err, errors.ErrNotFound) {
RespondNotFound(c, err.Error()) RespondNotFound(c, err.Error())
return return
} }
if errors.Is(err, errors.ErrProfileNoPermission) || if errors.Is(err, errors.ErrProfileNoPermission) ||
errors.Is(err, errors.ErrTextureNoPermission) || errors.Is(err, errors.ErrTextureNoPermission) ||
errors.Is(err, errors.ErrForbidden) { errors.Is(err, errors.ErrForbidden) {
RespondForbidden(c, err.Error()) RespondForbidden(c, err.Error())
return return
} }
if errors.Is(err, errors.ErrUnauthorized) || if errors.Is(err, errors.ErrUnauthorized) ||
errors.Is(err, errors.ErrInvalidToken) || errors.Is(err, errors.ErrInvalidToken) ||
errors.Is(err, errors.ErrTokenExpired) { errors.Is(err, errors.ErrTokenExpired) {
RespondUnauthorized(c, err.Error()) RespondUnauthorized(c, err.Error())
return return
} }

View File

@@ -207,39 +207,3 @@ func (h *ProfileHandler) Delete(c *gin.Context) {
RespondSuccess(c, gin.H{"message": "删除成功"}) RespondSuccess(c, gin.H{"message": "删除成功"})
} }
// SetActive 设置活跃档案
// @Summary 设置活跃档案
// @Description 将指定档案设置为活跃状态
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param uuid path string true "档案UUID"
// @Success 200 {object} model.Response "设置成功"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Router /api/v1/profile/{uuid}/activate [post]
func (h *ProfileHandler) SetActive(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
uuid := c.Param("uuid")
if uuid == "" {
RespondBadRequest(c, "UUID不能为空", nil)
return
}
if err := h.container.ProfileService.SetActive(c.Request.Context(), uuid, userID); err != nil {
h.logger.Error("设置活跃档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID),
zap.Error(err),
)
RespondWithError(c, err)
return
}
RespondSuccess(c, gin.H{"message": "设置成功"})
}

View File

@@ -7,6 +7,8 @@ import (
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
) )
// Handlers 集中管理所有Handler // Handlers 集中管理所有Handler
@@ -38,6 +40,9 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由 // 健康检查路由
router.GET("/health", HealthCheck) router.GET("/health", HealthCheck)
// Swagger文档路由
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 创建Handler实例 // 创建Handler实例
h := NewHandlers(c) h := NewHandlers(c)
@@ -90,8 +95,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW
userGroup.PUT("/profile", h.UpdateProfile) userGroup.PUT("/profile", h.UpdateProfile)
// 头像相关 // 头像相关
userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL) userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
userGroup.PUT("/avatar", h.UpdateAvatar) userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL外部URL
// 更换邮箱 // 更换邮箱
userGroup.POST("/change-email", h.ChangeEmail) userGroup.POST("/change-email", h.ChangeEmail)
@@ -108,14 +113,16 @@ 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("")
textureAuth.Use(middleware.AuthMiddleware(jwtService)) textureAuth.Use(middleware.AuthMiddleware(jwtService))
{ {
textureAuth.POST("/upload", h.Upload) // 直接上传文件 textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL保留兼容性
textureAuth.POST("", h.Create) // 创建材质记录配合预签名URL使用
textureAuth.PUT("/:id", h.Update) textureAuth.PUT("/:id", h.Update)
textureAuth.DELETE("/:id", h.Delete) textureAuth.DELETE("/:id", h.Delete)
textureAuth.POST("/:id/favorite", h.ToggleFavorite) textureAuth.POST("/:id/favorite", h.ToggleFavorite)
@@ -143,7 +150,6 @@ func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtServ
profileAuth.GET("/", h.List) profileAuth.GET("/", h.List)
profileAuth.PUT("/:uuid", h.Update) profileAuth.PUT("/:uuid", h.Update)
profileAuth.DELETE("/:uuid", h.Delete) profileAuth.DELETE("/:uuid", h.Delete)
profileAuth.POST("/:uuid/activate", h.SetActive)
} }
} }
} }

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types" "carrotskin/internal/types"
"strconv" "strconv"
@@ -24,93 +25,6 @@ func NewTextureHandler(c *container.Container) *TextureHandler {
} }
} }
// GenerateUploadURL 生成材质上传URL
func (h *TextureHandler) GenerateUploadURL(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.GenerateTextureUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "请求参数错误", err)
return
}
if h.container.Storage == nil {
RespondServerError(c, "存储服务不可用", nil)
return
}
result, err := h.container.UploadService.GenerateTextureUploadURL(
c.Request.Context(),
userID,
req.FileName,
string(req.TextureType),
)
if err != nil {
h.logger.Error("生成材质上传URL失败",
zap.Int64("user_id", userID),
zap.String("file_name", req.FileName),
zap.String("texture_type", string(req.TextureType)),
zap.Error(err),
)
RespondBadRequest(c, err.Error(), nil)
return
}
RespondSuccess(c, &types.GenerateTextureUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
TextureURL: result.FileURL,
ExpiresIn: 900,
})
}
// Create 创建材质记录
func (h *TextureHandler) Create(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.CreateTextureRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "请求参数错误", err)
return
}
maxTextures := h.container.UserService.GetMaxTexturesPerUser()
if err := h.container.TextureService.CheckUploadLimit(c.Request.Context(), userID, maxTextures); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
texture, err := h.container.TextureService.Create(
c.Request.Context(),
userID,
req.Name,
req.Description,
string(req.Type),
req.URL,
req.Hash,
req.Size,
req.IsPublic,
req.IsSlim,
)
if err != nil {
h.logger.Error("创建材质失败",
zap.Int64("user_id", userID),
zap.String("name", req.Name),
zap.Error(err),
)
RespondBadRequest(c, err.Error(), nil)
return
}
RespondSuccess(c, TextureToTextureInfo(texture))
}
// Get 获取材质详情 // Get 获取材质详情
func (h *TextureHandler) Get(c *gin.Context) { func (h *TextureHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -171,6 +85,98 @@ func (h *TextureHandler) Search(c *gin.Context) {
}) })
} }
// RenderTexture 渲染皮肤/披风预览
func (h *TextureHandler) RenderTexture(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
renderType := service.RenderType(c.DefaultQuery("type", string(service.RenderTypeIsometric)))
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderTexture(c.Request.Context(), textureID, renderType, size, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// RenderAvatar 渲染头像2D/3D
func (h *TextureHandler) RenderAvatar(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
mode := service.AvatarMode(c.DefaultQuery("mode", string(service.AvatarMode2D)))
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderAvatar(c.Request.Context(), textureID, size, mode, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// RenderCape 渲染披风
func (h *TextureHandler) RenderCape(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderCape(c.Request.Context(), textureID, size, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// RenderPreview 自动选择预览(皮肤走等距,披风走披风渲染)
func (h *TextureHandler) RenderPreview(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
size := parseIntWithDefault(c.DefaultQuery("size", "256"), 256)
format := service.ImageFormat(c.DefaultQuery("format", string(service.ImageFormatPNG)))
result, err := h.container.TextureRenderService.RenderPreview(c.Request.Context(), textureID, size, format)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, toRenderResponse(result))
}
// toRenderResponse 转换为API响应
func toRenderResponse(r *service.RenderResult) *types.RenderResponse {
if r == nil {
return nil
}
resp := &types.RenderResponse{
URL: r.URL,
ContentType: r.ContentType,
ETag: r.ETag,
Size: r.Size,
}
if !r.LastModified.IsZero() {
t := r.LastModified
resp.LastModified = &t
}
return resp
}
// Update 更新材质 // Update 更新材质
func (h *TextureHandler) Update(c *gin.Context) { func (h *TextureHandler) Update(c *gin.Context) {
userID, ok := GetUserIDFromContext(c) userID, ok := GetUserIDFromContext(c)

View File

@@ -102,44 +102,66 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
RespondSuccess(c, UserToUserInfo(updatedUser)) RespondSuccess(c, UserToUserInfo(updatedUser))
} }
// GenerateAvatarUploadURL 生成头像上传URL // UploadAvatar 直接上传头像文件
func (h *UserHandler) GenerateAvatarUploadURL(c *gin.Context) { func (h *UserHandler) UploadAvatar(c *gin.Context) {
userID, ok := GetUserIDFromContext(c) userID, ok := GetUserIDFromContext(c)
if !ok { if !ok {
return return
} }
var req types.GenerateAvatarUploadURLRequest // 解析multipart表单
if err := c.ShouldBindJSON(&req); err != nil { if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB
RespondBadRequest(c, "请求参数错误", err) RespondBadRequest(c, "解析表单失败", err)
return return
} }
if h.container.Storage == nil { // 获取文件
RespondServerError(c, "存储服务不可用", nil) file, err := c.FormFile("file")
return
}
result, err := h.container.UploadService.GenerateAvatarUploadURL(c.Request.Context(), userID, req.FileName)
if err != nil { if err != nil {
h.logger.Error("生成头像上传URL失败", RespondBadRequest(c, "获取文件失败", err)
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
RespondBadRequest(c, "打开文件失败", err)
return
}
defer src.Close()
fileData := make([]byte, file.Size)
if _, err := src.Read(fileData); err != nil {
RespondBadRequest(c, "读取文件失败", err)
return
}
// 调用服务上传头像
avatarURL, err := h.container.UserService.UploadAvatar(c.Request.Context(), userID, fileData, file.Filename)
if err != nil {
h.logger.Error("上传头像失败",
zap.Int64("user_id", userID), zap.Int64("user_id", userID),
zap.String("file_name", req.FileName), zap.String("file_name", file.Filename),
zap.Error(err), zap.Error(err),
) )
RespondBadRequest(c, err.Error(), nil) RespondBadRequest(c, err.Error(), nil)
return return
} }
RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{ // 获取更新后的用户信息
PostURL: result.PostURL, user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
FormData: result.FormData, if err != nil || user == nil {
AvatarURL: result.FileURL, RespondNotFound(c, "用户不存在")
ExpiresIn: 900, return
}
RespondSuccess(c, gin.H{
"avatar_url": avatarURL,
"user": UserToUserInfo(user),
}) })
} }
// UpdateAvatar 更新头像URL // UpdateAvatar 更新头像URL保留用于外部URL
func (h *UserHandler) UpdateAvatar(c *gin.Context) { func (h *UserHandler) UpdateAvatar(c *gin.Context) {
userID, ok := GetUserIDFromContext(c) userID, ok := GetUserIDFromContext(c)
if !ok { if !ok {

View File

@@ -23,3 +23,9 @@ type BaseModel struct {
} }

View File

@@ -7,12 +7,11 @@ import (
// Profile Minecraft 档案模型 // Profile Minecraft 档案模型
type Profile struct { type Profile struct {
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"` UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"`
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1;index:idx_profiles_user_active,priority:1" json:"user_id"` UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"` SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"` CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端 RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端
IsActive bool `gorm:"column:is_active;not null;default:true;index:idx_profiles_user_active,priority:2" json:"is_active"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"` LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
@@ -33,7 +32,6 @@ type ProfileResponse struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Textures ProfileTexturesData `json:"textures"` Textures ProfileTexturesData `json:"textures"`
IsActive bool `json:"is_active"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }

View File

@@ -35,7 +35,6 @@ type ProfileRepository interface {
Delete(ctx context.Context, uuid string) error Delete(ctx context.Context, uuid string) error
BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除 BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除
CountByUserID(ctx context.Context, userID int64) (int64, error) CountByUserID(ctx context.Context, userID int64) (int64, error)
SetActive(ctx context.Context, uuid string, userID int64) error
UpdateLastUsedAt(ctx context.Context, uuid string) error UpdateLastUsedAt(ctx context.Context, uuid string) error
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error) GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error)

View File

@@ -109,20 +109,6 @@ func (r *profileRepository) CountByUserID(ctx context.Context, userID int64) (in
return count, err return count, err
} }
func (r *profileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Profile{}).
Where("user_id = ?", userID).
Update("is_active", false).Error; err != nil {
return err
}
return tx.Model(&model.Profile{}).
Where("uuid = ? AND user_id = ?", uuid, userID).
Update("is_active", true).Error
})
}
func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error { func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
return r.db.WithContext(ctx).Model(&model.Profile{}). return r.db.WithContext(ctx).Model(&model.Profile{}).
Where("uuid = ?", uuid). Where("uuid = ?", uuid).

View File

@@ -42,41 +42,6 @@ func TestProfileRepository_QueryConditions(t *testing.T) {
} }
} }
// TestProfileRepository_SetActiveLogic 测试设置活跃档案的逻辑
func TestProfileRepository_SetActiveLogic(t *testing.T) {
tests := []struct {
name string
uuid string
userID int64
otherProfiles int
wantAllInactive bool
}{
{
name: "设置一个档案为活跃,其他应该变为非活跃",
uuid: "profile-1",
userID: 1,
otherProfiles: 2,
wantAllInactive: true,
},
{
name: "只有一个档案时",
uuid: "profile-1",
userID: 1,
otherProfiles: 0,
wantAllInactive: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证逻辑:设置一个档案为活跃时,应该先将所有档案设为非活跃
if !tt.wantAllInactive {
t.Error("Setting active profile should first set all profiles to inactive")
}
})
}
}
// TestProfileRepository_CountLogic 测试统计逻辑 // TestProfileRepository_CountLogic 测试统计逻辑
func TestProfileRepository_CountLogic(t *testing.T) { func TestProfileRepository_CountLogic(t *testing.T) {
tests := []struct { tests := []struct {
@@ -109,30 +74,30 @@ func TestProfileRepository_CountLogic(t *testing.T) {
// TestProfileRepository_UpdateFieldsLogic 测试更新字段逻辑 // TestProfileRepository_UpdateFieldsLogic 测试更新字段逻辑
func TestProfileRepository_UpdateFieldsLogic(t *testing.T) { func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
uuid string uuid string
updates map[string]interface{} updates map[string]interface{}
wantValid bool wantValid bool
}{ }{
{ {
name: "有效的更新", name: "有效的更新",
uuid: "123e4567-e89b-12d3-a456-426614174000", uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{ updates: map[string]interface{}{
"name": "NewName", "name": "NewName",
"skin_id": int64(1), "skin_id": int64(1),
}, },
wantValid: true, wantValid: true,
}, },
{ {
name: "UUID为空", name: "UUID为空",
uuid: "", uuid: "",
updates: map[string]interface{}{"name": "NewName"}, updates: map[string]interface{}{"name": "NewName"},
wantValid: false, wantValid: false,
}, },
{ {
name: "更新字段为空", name: "更新字段为空",
uuid: "123e4567-e89b-12d3-a456-426614174000", uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{}, updates: map[string]interface{}{},
wantValid: true, // 空更新也是有效的,只是不会更新任何字段 wantValid: true, // 空更新也是有效的,只是不会更新任何字段
}, },
} }
@@ -150,24 +115,24 @@ func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
// TestProfileRepository_FindOneProfileLogic 测试查找单个档案的逻辑 // TestProfileRepository_FindOneProfileLogic 测试查找单个档案的逻辑
func TestProfileRepository_FindOneProfileLogic(t *testing.T) { func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
profileCount int profileCount int
wantError bool wantError bool
}{ }{
{ {
name: "有档案时返回第一个", name: "有档案时返回第一个",
profileCount: 1, profileCount: 1,
wantError: false, wantError: false,
}, },
{ {
name: "多个档案时返回第一个", name: "多个档案时返回第一个",
profileCount: 3, profileCount: 3,
wantError: false, wantError: false,
}, },
{ {
name: "没有档案时应该错误", name: "没有档案时应该错误",
profileCount: 0, profileCount: 0,
wantError: true, wantError: true,
}, },
} }
@@ -181,4 +146,3 @@ func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
}) })
} }
} }

View File

@@ -29,3 +29,9 @@ func (r *yggdrasilRepository) GetPasswordByID(ctx context.Context, id int64) (st
func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error { func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error {
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
} }

View File

@@ -27,6 +27,9 @@ type UserService interface {
ResetPassword(ctx context.Context, email, newPassword string) error ResetPassword(ctx context.Context, email, newPassword string) error
ChangeEmail(ctx context.Context, userID int64, newEmail string) error ChangeEmail(ctx context.Context, userID int64, newEmail string) error
// 头像上传
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
// URL验证 // URL验证
ValidateAvatarURL(ctx context.Context, avatarURL string) error ValidateAvatarURL(ctx context.Context, avatarURL string) error
@@ -45,7 +48,6 @@ type ProfileService interface {
Delete(ctx context.Context, uuid string, userID int64) error Delete(ctx context.Context, uuid string, userID int64) error
// 档案状态 // 档案状态
SetActive(ctx context.Context, uuid string, userID int64) error
CheckLimit(ctx context.Context, userID int64, maxProfiles int) error CheckLimit(ctx context.Context, userID int64, maxProfiles int) error
// 批量查询 // 批量查询
@@ -56,8 +58,7 @@ type ProfileService interface {
// TextureService 材质服务接口 // TextureService 材质服务接口
type TextureService interface { type TextureService interface {
// 材质CRUD // 材质CRUD
Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error)
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) // 直接上传材质文件
GetByID(ctx context.Context, id int64) (*model.Texture, error) GetByID(ctx context.Context, id int64) (*model.Texture, error)
GetByHash(ctx context.Context, hash string) (*model.Texture, error) GetByHash(ctx context.Context, hash string) (*model.Texture, error)
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
@@ -99,12 +100,6 @@ type CaptchaService interface {
Verify(ctx context.Context, dx int, captchaID string) (bool, error) Verify(ctx context.Context, dx int, captchaID string) (bool, error)
} }
// UploadService 上传服务接口
type UploadService interface {
GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error)
GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error)
}
// YggdrasilService Yggdrasil服务接口 // YggdrasilService Yggdrasil服务接口
type YggdrasilService interface { type YggdrasilService interface {
// 用户认证 // 用户认证
@@ -141,6 +136,69 @@ type SecurityService interface {
ClearVerifyAttempts(ctx context.Context, email, codeType string) error ClearVerifyAttempts(ctx context.Context, email, codeType string) error
} }
// TextureRenderService 纹理渲染服务接口
type TextureRenderService interface {
// RenderTexture 渲染纹理为预览图
RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error)
// RenderTextureFromData 从原始数据渲染纹理
RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error)
// GetRenderURL 获取渲染图的URL
GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string
// DeleteRenderCache 删除渲染缓存
DeleteRenderCache(ctx context.Context, textureID int64) error
// RenderAvatar 渲染头像支持2D/3D模式
RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error)
// RenderCape 渲染披风
RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
// RenderPreview 渲染预览图类似Blessing Skin的preview功能
RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
}
// RenderType 渲染类型
type RenderType string
const (
RenderTypeFront RenderType = "front" // 正面
RenderTypeBack RenderType = "back" // 背面
RenderTypeFull RenderType = "full" // 全身
RenderTypeHead RenderType = "head" // 头像
RenderTypeIsometric RenderType = "isometric" // 等距视图
)
// ImageFormat 输出格式
type ImageFormat string
const (
ImageFormatPNG ImageFormat = "png"
ImageFormatWEBP ImageFormat = "webp"
)
// AvatarMode 头像模式
type AvatarMode string
const (
AvatarMode2D AvatarMode = "2d" // 2D头像
AvatarMode3D AvatarMode = "3d" // 3D头像
)
// TextureType 纹理类型
type TextureType string
const (
TextureTypeSteve TextureType = "steve" // Steve皮肤
TextureTypeAlex TextureType = "alex" // Alex皮肤
TextureTypeCape TextureType = "cape" // 披风
)
// RenderResult 渲染结果(附带缓存/HTTP头信息
type RenderResult struct {
URL string
ContentType string
ETag string
LastModified time.Time
Size int64
}
// Services 服务集合 // Services 服务集合
type Services struct { type Services struct {
User UserService User UserService
@@ -149,7 +207,6 @@ type Services struct {
Token TokenService Token TokenService
Verification VerificationService Verification VerificationService
Captcha CaptchaService Captcha CaptchaService
Upload UploadService
Yggdrasil YggdrasilService Yggdrasil YggdrasilService
Security SecurityService Security SecurityService
} }

View File

@@ -214,10 +214,6 @@ func (m *MockProfileRepository) CountByUserID(ctx context.Context, userID int64)
return int64(len(m.userProfiles[userID])), nil return int64(len(m.userProfiles[userID])), nil
} }
func (m *MockProfileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
return nil
}
func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error { func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
return nil return nil
} }
@@ -315,6 +311,18 @@ func (m *MockTextureRepository) FindByHash(ctx context.Context, hash string) (*m
return nil, nil return nil, nil
} }
func (m *MockTextureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
for _, texture := range m.textures {
if texture.Hash == hash && texture.UploaderID == uploaderID {
return texture, nil
}
}
return nil, nil
}
func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) { func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if m.FailFind { if m.FailFind {
return nil, 0, errors.New("mock find error") return nil, 0, errors.New("mock find error")
@@ -796,10 +804,6 @@ func (m *MockProfileService) Delete(uuid string, userID int64) error {
return nil return nil
} }
func (m *MockProfileService) SetActive(uuid string, userID int64) error {
return nil
}
func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error { func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error {
count := 0 count := 0
for _, profile := range m.profiles { for _, profile := range m.profiles {

View File

@@ -77,18 +77,12 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
UserID: userID, UserID: userID,
Name: name, Name: name,
RSAPrivateKey: privateKey, RSAPrivateKey: privateKey,
IsActive: true,
} }
if err := s.profileRepo.Create(ctx, profile); err != nil { if err := s.profileRepo.Create(ctx, profile); err != nil {
return nil, fmt.Errorf("创建档案失败: %w", err) return nil, fmt.Errorf("创建档案失败: %w", err)
} }
// 设置活跃状态
if err := s.profileRepo.SetActive(ctx, profileUUID, userID); err != nil {
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
}
// 清除用户的 profile 列表缓存 // 清除用户的 profile 列表缓存
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID)) s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
@@ -220,34 +214,6 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
return nil return nil
} }
func (s *profileService) SetActive(ctx context.Context, uuid string, userID int64) error {
// 获取档案并验证权限
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProfileNotFound
}
return fmt.Errorf("查询档案失败: %w", err)
}
if profile.UserID != userID {
return ErrProfileNoPermission
}
if err := s.profileRepo.SetActive(ctx, uuid, userID); err != nil {
return fmt.Errorf("设置活跃状态失败: %w", err)
}
if err := s.profileRepo.UpdateLastUsedAt(ctx, uuid); err != nil {
return fmt.Errorf("更新使用时间失败: %w", err)
}
// 清除该用户所有 profile 的缓存(因为活跃状态改变了)
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.ProfilePattern(userID))
return nil
}
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error { func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
count, err := s.profileRepo.CountByUserID(ctx, userID) count, err := s.profileRepo.CountByUserID(ctx, userID)
if err != nil { if err != nil {

View File

@@ -80,15 +80,6 @@ func TestProfileService_StatusValidation(t *testing.T) {
} }
} }
// TestProfileService_IsActiveDefault 测试Profile默认活跃状态
func TestProfileService_IsActiveDefault(t *testing.T) {
// 新创建的档案默认为活跃状态
isActive := true
if !isActive {
t.Error("新创建的Profile应该默认为活跃状态")
}
}
// TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑 // TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑
func TestUpdateProfile_PermissionCheck(t *testing.T) { func TestUpdateProfile_PermissionCheck(t *testing.T) {
tests := []struct { tests := []struct {
@@ -191,38 +182,6 @@ func TestDeleteProfile_PermissionCheck(t *testing.T) {
} }
} }
// TestSetActiveProfile_PermissionCheck 测试设置活跃Profile的权限检查
func TestSetActiveProfile_PermissionCheck(t *testing.T) {
tests := []struct {
name string
profileUserID int64
requestUserID int64
wantErr bool
}{
{
name: "用户ID匹配允许设置",
profileUserID: 1,
requestUserID: 1,
wantErr: false,
},
{
name: "用户ID不匹配拒绝设置",
profileUserID: 1,
requestUserID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.profileUserID != tt.requestUserID
if hasError != tt.wantErr {
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑 // TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑
func TestCheckProfileLimit_Logic(t *testing.T) { func TestCheckProfileLimit_Logic(t *testing.T) {
tests := []struct { tests := []struct {
@@ -642,8 +601,8 @@ func TestProfileServiceImpl_GetByUserID(t *testing.T) {
} }
} }
// TestProfileServiceImpl_Update_And_SetActive 测试 Update 与 SetActive // TestProfileServiceImpl_Update 测试 Update
func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) { func TestProfileServiceImpl_Update(t *testing.T) {
profileRepo := NewMockProfileRepository() profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
logger := zap.NewNop() logger := zap.NewNop()
@@ -686,16 +645,6 @@ func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) {
if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil { if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil {
t.Fatalf("Update 在名称重复时应返回错误") t.Fatalf("Update 在名称重复时应返回错误")
} }
// SetActive 正常
if err := svc.SetActive(ctx, "u1", 1); err != nil {
t.Fatalf("SetActive 正常情况失败: %v", err)
}
// SetActive 无权限
if err := svc.SetActive(ctx, "u1", 2); err == nil {
t.Fatalf("SetActive 在无权限时应返回错误")
}
} }
// TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName // TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName

View File

@@ -0,0 +1,121 @@
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
}

View File

@@ -0,0 +1,113 @@
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
}

View File

@@ -0,0 +1,95 @@
// 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
}

View File

@@ -0,0 +1,200 @@
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

View File

@@ -0,0 +1,591 @@
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),
}
}

View File

@@ -0,0 +1,203 @@
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("多边形应该在图像上绘制了像素")
}
}

View File

@@ -0,0 +1,808 @@
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
}

View File

@@ -48,57 +48,6 @@ func NewTextureService(
} }
} }
func (s *textureService) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil {
return nil, ErrUserNotFound
}
// 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
// 如果已存在相同Hash的皮肤复用已存在的URL
finalURL := url
if existingTexture != nil {
finalURL = existingTexture.URL
}
// 转换材质类型
textureTypeEnum, err := parseTextureTypeInternal(textureType)
if err != nil {
return nil, err
}
// 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: finalURL, // 复用已存在的URL或使用新URL
Hash: hash,
Size: size,
IsPublic: isPublic,
IsSlim: isSlim,
Status: 1,
DownloadCount: 0,
FavoriteCount: 0,
}
if err := s.textureRepo.Create(ctx, texture); err != nil {
return nil, err
}
// 清除用户的 texture 列表缓存(所有分页)
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
return texture, nil
}
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) { func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
// 尝试从缓存获取 // 尝试从缓存获取
cacheKey := s.cacheKeys.Texture(id) cacheKey := s.cacheKeys.Texture(id)

View File

@@ -478,129 +478,6 @@ func boolPtr(b bool) *bool {
// 使用 Mock 的集成测试 // 使用 Mock 的集成测试
// ============================================================================ // ============================================================================
// TestTextureServiceImpl_Create 测试创建Texture
func TestTextureServiceImpl_Create(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户
testUser := &model.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
Status: 1,
}
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
uploaderID int64
textureName string
textureType string
hash string
wantErr bool
errContains string
setupMocks func()
}{
{
name: "正常创建SKIN材质",
uploaderID: 1,
textureName: "TestSkin",
textureType: "SKIN",
hash: "unique-hash-1",
wantErr: false,
},
{
name: "正常创建CAPE材质",
uploaderID: 1,
textureName: "TestCape",
textureType: "CAPE",
hash: "unique-hash-2",
wantErr: false,
},
{
name: "用户不存在",
uploaderID: 999,
textureName: "TestTexture",
textureType: "SKIN",
hash: "unique-hash-3",
wantErr: true,
},
{
name: "材质Hash已存在",
uploaderID: 1,
textureName: "DuplicateTexture",
textureType: "SKIN",
hash: "existing-hash",
wantErr: true,
errContains: "已存在",
setupMocks: func() {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: "existing-hash",
})
},
},
{
name: "无效的材质类型",
uploaderID: 1,
textureName: "InvalidTypeTexture",
textureType: "INVALID",
hash: "unique-hash-4",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks != nil {
tt.setupMocks()
}
ctx := context.Background()
texture, err := textureService.Create(
ctx,
tt.uploaderID,
tt.textureName,
"Test description",
tt.textureType,
"http://example.com/texture.png",
tt.hash,
512,
true,
false,
)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Error("返回的Texture不应为nil")
}
if texture.Name != tt.textureName {
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
}
}
})
}
}
// TestTextureServiceImpl_GetByID 测试获取Texture // TestTextureServiceImpl_GetByID 测试获取Texture
func TestTextureServiceImpl_GetByID(t *testing.T) { func TestTextureServiceImpl_GetByID(t *testing.T) {
textureRepo := NewMockTextureRepository() textureRepo := NewMockTextureRepository()
@@ -617,7 +494,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture) _ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
tests := []struct { tests := []struct {
name string name string
@@ -675,7 +552,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background() ctx := context.Background()
@@ -714,7 +591,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
_ = textureRepo.Create(context.Background(), texture) _ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background() ctx := context.Background()
@@ -764,7 +641,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background() ctx := context.Background()
@@ -807,7 +684,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture) _ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background() ctx := context.Background()

View File

@@ -203,10 +203,9 @@ func TestTokenServiceImpl_Create(t *testing.T) {
// 预置Profile // 预置Profile
testProfile := &model.Profile{ testProfile := &model.Profile{
UUID: "test-profile-uuid", UUID: "test-profile-uuid",
UserID: 1, UserID: 1,
Name: "TestProfile", Name: "TestProfile",
IsActive: true,
} }
_ = profileRepo.Create(context.Background(), testProfile) _ = profileRepo.Create(context.Background(), testProfile)

View File

@@ -1,167 +0,0 @@
package service
import (
"carrotskin/pkg/storage"
"context"
"fmt"
"path/filepath"
"strings"
"time"
)
// FileType 文件类型枚举
type FileType string
const (
FileTypeAvatar FileType = "avatar"
FileTypeTexture FileType = "texture"
)
// UploadConfig 上传配置
type UploadConfig struct {
AllowedExts map[string]bool // 允许的文件扩展名
MinSize int64 // 最小文件大小(字节)
MaxSize int64 // 最大文件大小(字节)
Expires time.Duration // URL过期时间
}
// uploadService UploadService的实现
type uploadService struct {
storage *storage.StorageClient
}
// NewUploadService 创建UploadService实例
func NewUploadService(storageClient *storage.StorageClient) UploadService {
return &uploadService{
storage: storageClient,
}
}
// GenerateAvatarUploadURL 生成头像上传URL
func (s *uploadService) GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) {
// 1. 验证文件名
if err := ValidateFileName(fileName, FileTypeAvatar); err != nil {
return nil, err
}
// 2. 获取上传配置
uploadConfig := GetUploadConfig(FileTypeAvatar)
// 3. 获取存储桶名称
bucketName, err := s.storage.GetBucket("avatars")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 4. 生成对象名称(路径)
// 格式: user_{userId}/timestamp_{originalFileName}
timestamp := time.Now().Format("20060102150405")
objectName := fmt.Sprintf("user_%d/%s_%s", userID, timestamp, fileName)
// 5. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
result, err := s.storage.GeneratePresignedPostURL(
ctx,
bucketName,
objectName,
uploadConfig.MinSize,
uploadConfig.MaxSize,
uploadConfig.Expires,
)
if err != nil {
return nil, fmt.Errorf("生成上传URL失败: %w", err)
}
return result, nil
}
// GenerateTextureUploadURL 生成材质上传URL
func (s *uploadService) GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) {
// 1. 验证文件名
if err := ValidateFileName(fileName, FileTypeTexture); err != nil {
return nil, err
}
// 2. 验证材质类型
if textureType != "SKIN" && textureType != "CAPE" {
return nil, fmt.Errorf("无效的材质类型: %s", textureType)
}
// 3. 获取上传配置
uploadConfig := GetUploadConfig(FileTypeTexture)
// 4. 获取存储桶名称
bucketName, err := s.storage.GetBucket("textures")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 5. 生成对象名称(路径)
// 格式: user_{userId}/{textureType}/timestamp_{originalFileName}
timestamp := time.Now().Format("20060102150405")
textureTypeFolder := strings.ToLower(textureType)
objectName := fmt.Sprintf("user_%d/%s/%s_%s", userID, textureTypeFolder, timestamp, fileName)
// 6. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
result, err := s.storage.GeneratePresignedPostURL(
ctx,
bucketName,
objectName,
uploadConfig.MinSize,
uploadConfig.MaxSize,
uploadConfig.Expires,
)
if err != nil {
return nil, fmt.Errorf("生成上传URL失败: %w", err)
}
return result, nil
}
// GetUploadConfig 根据文件类型获取上传配置
func GetUploadConfig(fileType FileType) *UploadConfig {
switch fileType {
case FileTypeAvatar:
return &UploadConfig{
AllowedExts: map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
},
MinSize: 512, // 512B
MaxSize: 5 * 1024 * 1024, // 5MB
Expires: 15 * time.Minute,
}
case FileTypeTexture:
return &UploadConfig{
AllowedExts: map[string]bool{
".png": true,
},
MinSize: 512, // 512B
MaxSize: 10 * 1024 * 1024, // 10MB
Expires: 15 * time.Minute,
}
default:
return nil
}
}
// ValidateFileName 验证文件名
func ValidateFileName(fileName string, fileType FileType) error {
if fileName == "" {
return fmt.Errorf("文件名不能为空")
}
uploadConfig := GetUploadConfig(fileType)
if uploadConfig == nil {
return fmt.Errorf("不支持的文件类型")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !uploadConfig.AllowedExts[ext] {
return fmt.Errorf("不支持的文件格式: %s", ext)
}
return nil
}

View File

@@ -1,389 +0,0 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"carrotskin/pkg/storage"
)
// TestUploadService_FileTypes 测试文件类型常量
func TestUploadService_FileTypes(t *testing.T) {
if FileTypeAvatar == "" {
t.Error("FileTypeAvatar should not be empty")
}
if FileTypeTexture == "" {
t.Error("FileTypeTexture should not be empty")
}
if FileTypeAvatar == FileTypeTexture {
t.Error("FileTypeAvatar and FileTypeTexture should be different")
}
}
// TestGetUploadConfig 测试获取上传配置
func TestGetUploadConfig(t *testing.T) {
tests := []struct {
name string
fileType FileType
wantConfig bool
}{
{
name: "头像类型返回配置",
fileType: FileTypeAvatar,
wantConfig: true,
},
{
name: "材质类型返回配置",
fileType: FileTypeTexture,
wantConfig: true,
},
{
name: "无效类型返回nil",
fileType: FileType("invalid"),
wantConfig: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := GetUploadConfig(tt.fileType)
hasConfig := config != nil
if hasConfig != tt.wantConfig {
t.Errorf("GetUploadConfig() = %v, want %v", hasConfig, tt.wantConfig)
}
if config != nil {
// 验证配置字段
if config.MinSize <= 0 {
t.Error("MinSize should be greater than 0")
}
if config.MaxSize <= 0 {
t.Error("MaxSize should be greater than 0")
}
if config.MaxSize < config.MinSize {
t.Error("MaxSize should be greater than or equal to MinSize")
}
if config.Expires <= 0 {
t.Error("Expires should be greater than 0")
}
if len(config.AllowedExts) == 0 {
t.Error("AllowedExts should not be empty")
}
}
})
}
}
// TestGetUploadConfig_AvatarConfig 测试头像配置详情
func TestGetUploadConfig_AvatarConfig(t *testing.T) {
config := GetUploadConfig(FileTypeAvatar)
if config == nil {
t.Fatal("Avatar config should not be nil")
}
// 验证允许的扩展名
expectedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
for _, ext := range expectedExts {
if !config.AllowedExts[ext] {
t.Errorf("Avatar config should allow %s extension", ext)
}
}
// 验证文件大小限制
if config.MinSize != 512 {
t.Errorf("Avatar MinSize = %d, want 512", config.MinSize)
}
if config.MaxSize != 5*1024*1024 {
t.Errorf("Avatar MaxSize = %d, want 5MB", config.MaxSize)
}
// 验证过期时间
if config.Expires != 15*time.Minute {
t.Errorf("Avatar Expires = %v, want 15 minutes", config.Expires)
}
}
// TestGetUploadConfig_TextureConfig 测试材质配置详情
func TestGetUploadConfig_TextureConfig(t *testing.T) {
config := GetUploadConfig(FileTypeTexture)
if config == nil {
t.Fatal("Texture config should not be nil")
}
// 验证允许的扩展名材质只允许PNG
if !config.AllowedExts[".png"] {
t.Error("Texture config should allow .png extension")
}
// 验证文件大小限制
if config.MinSize != 512 {
t.Errorf("Texture MinSize = %d, want 512", config.MinSize)
}
if config.MaxSize != 10*1024*1024 {
t.Errorf("Texture MaxSize = %d, want 10MB", config.MaxSize)
}
// 验证过期时间
if config.Expires != 15*time.Minute {
t.Errorf("Texture Expires = %v, want 15 minutes", config.Expires)
}
}
// TestValidateFileName 测试文件名验证
func TestValidateFileName(t *testing.T) {
tests := []struct {
name string
fileName string
fileType FileType
wantErr bool
errContains string
}{
{
name: "有效的头像文件名",
fileName: "avatar.png",
fileType: FileTypeAvatar,
wantErr: false,
},
{
name: "有效的材质文件名",
fileName: "texture.png",
fileType: FileTypeTexture,
wantErr: false,
},
{
name: "文件名为空",
fileName: "",
fileType: FileTypeAvatar,
wantErr: true,
errContains: "文件名不能为空",
},
{
name: "不支持的文件扩展名",
fileName: "file.txt",
fileType: FileTypeAvatar,
wantErr: true,
errContains: "不支持的文件格式",
},
{
name: "无效的文件类型",
fileName: "file.png",
fileType: FileType("invalid"),
wantErr: true,
errContains: "不支持的文件类型",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFileName(tt.fileName, tt.fileType)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFileName() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && tt.errContains != "" {
if err == nil || !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("ValidateFileName() error = %v, should contain %s", err, tt.errContains)
}
}
})
}
}
// TestValidateFileName_Extensions 测试各种扩展名
func TestValidateFileName_Extensions(t *testing.T) {
avatarExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
for _, ext := range avatarExts {
fileName := "test" + ext
err := ValidateFileName(fileName, FileTypeAvatar)
if err != nil {
t.Errorf("Avatar file with %s extension should be valid, got error: %v", ext, err)
}
}
// 材质只支持PNG
textureExts := []string{".png"}
for _, ext := range textureExts {
fileName := "test" + ext
err := ValidateFileName(fileName, FileTypeTexture)
if err != nil {
t.Errorf("Texture file with %s extension should be valid, got error: %v", ext, err)
}
}
// 测试不支持的扩展名
invalidExts := []string{".txt", ".pdf", ".doc"}
for _, ext := range invalidExts {
fileName := "test" + ext
err := ValidateFileName(fileName, FileTypeAvatar)
if err == nil {
t.Errorf("Avatar file with %s extension should be invalid", ext)
}
}
}
// TestValidateFileName_CaseInsensitive 测试扩展名大小写不敏感
func TestValidateFileName_CaseInsensitive(t *testing.T) {
testCases := []struct {
fileName string
fileType FileType
wantErr bool
}{
{"test.PNG", FileTypeAvatar, false},
{"test.JPG", FileTypeAvatar, false},
{"test.JPEG", FileTypeAvatar, false},
{"test.GIF", FileTypeAvatar, false},
{"test.WEBP", FileTypeAvatar, false},
{"test.PnG", FileTypeTexture, false},
}
for _, tc := range testCases {
t.Run(tc.fileName, func(t *testing.T) {
err := ValidateFileName(tc.fileName, tc.fileType)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateFileName(%s, %s) error = %v, wantErr %v", tc.fileName, tc.fileType, err, tc.wantErr)
}
})
}
}
// TestUploadConfig_Structure 测试UploadConfig结构
func TestUploadConfig_Structure(t *testing.T) {
config := &UploadConfig{
AllowedExts: map[string]bool{
".png": true,
},
MinSize: 512,
MaxSize: 5 * 1024 * 1024,
Expires: 15 * time.Minute,
}
if config.AllowedExts == nil {
t.Error("AllowedExts should not be nil")
}
if config.MinSize <= 0 {
t.Error("MinSize should be greater than 0")
}
if config.MaxSize <= config.MinSize {
t.Error("MaxSize should be greater than MinSize")
}
if config.Expires <= 0 {
t.Error("Expires should be greater than 0")
}
}
// mockStorageClient 用于单元测试的简单存储客户端假实现
// 注意:这里只声明与 upload_service 使用到的方法,避免依赖真实 MinIO 客户端
type mockStorageClient struct {
getBucketFn func(name string) (string, error)
generatePresignedPostURLFn func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error)
}
func (m *mockStorageClient) GetBucket(name string) (string, error) {
if m.getBucketFn != nil {
return m.getBucketFn(name)
}
return "", errors.New("GetBucket not implemented")
}
func (m *mockStorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
if m.generatePresignedPostURLFn != nil {
return m.generatePresignedPostURLFn(ctx, bucketName, objectName, minSize, maxSize, expires)
}
return nil, errors.New("GeneratePresignedPostURL not implemented")
}
// TestGenerateAvatarUploadURL_Success 测试头像上传URL生成成功
func TestGenerateAvatarUploadURL_Success(t *testing.T) {
// 由于 mockStorageClient 类型不匹配,跳过该测试
t.Skip("This test requires refactoring to work with the new service architecture")
_ = &mockStorageClient{
getBucketFn: func(name string) (string, error) {
if name != "avatars" {
t.Fatalf("unexpected bucket name: %s", name)
}
return "avatars-bucket", nil
},
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
if bucketName != "avatars-bucket" {
t.Fatalf("unexpected bucketName: %s", bucketName)
}
if !strings.Contains(objectName, "user_") {
t.Fatalf("objectName should contain user_ prefix, got: %s", objectName)
}
if !strings.Contains(objectName, "avatar.png") {
t.Fatalf("objectName should contain original file name, got: %s", objectName)
}
// 检查大小与过期时间传递
if minSize != 512 {
t.Fatalf("minSize = %d, want 512", minSize)
}
if maxSize != 5*1024*1024 {
t.Fatalf("maxSize = %d, want 5MB", maxSize)
}
if expires != 15*time.Minute {
t.Fatalf("expires = %v, want 15m", expires)
}
return &storage.PresignedPostPolicyResult{
PostURL: "http://example.com/upload",
FormData: map[string]string{"key": objectName},
FileURL: "http://example.com/file/" + objectName,
}, nil
},
}
}
// TestGenerateTextureUploadURL_Success 测试材质上传URL生成成功SKIN/CAPE
func TestGenerateTextureUploadURL_Success(t *testing.T) {
// 由于 mockStorageClient 类型不匹配,跳过该测试
t.Skip("This test requires refactoring to work with the new service architecture")
tests := []struct {
name string
textureType string
}{
{"SKIN 材质", "SKIN"},
{"CAPE 材质", "CAPE"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = &mockStorageClient{
getBucketFn: func(name string) (string, error) {
if name != "textures" {
t.Fatalf("unexpected bucket name: %s", name)
}
return "textures-bucket", nil
},
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
if bucketName != "textures-bucket" {
t.Fatalf("unexpected bucketName: %s", bucketName)
}
if !strings.Contains(objectName, "texture.png") {
t.Fatalf("objectName should contain original file name, got: %s", objectName)
}
if !strings.Contains(objectName, "/"+strings.ToLower(tt.textureType)+"/") {
t.Fatalf("objectName should contain texture type folder, got: %s", objectName)
}
return &storage.PresignedPostPolicyResult{
PostURL: "http://example.com/upload",
FormData: map[string]string{"key": objectName},
FileURL: "http://example.com/file/" + objectName,
}, nil
},
}
})
}
}

View File

@@ -1,10 +1,14 @@
package service package service
import ( import (
"bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -15,6 +19,7 @@ import (
"carrotskin/pkg/config" "carrotskin/pkg/config"
"carrotskin/pkg/database" "carrotskin/pkg/database"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -28,6 +33,7 @@ type userService struct {
cache *database.CacheManager cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator cacheInv *database.CacheInvalidator
storage *storage.StorageClient
logger *zap.Logger logger *zap.Logger
} }
@@ -38,6 +44,7 @@ func NewUserService(
jwtService *auth.JWTService, jwtService *auth.JWTService,
redisClient *redis.Client, redisClient *redis.Client,
cacheManager *database.CacheManager, cacheManager *database.CacheManager,
storageClient *storage.StorageClient,
logger *zap.Logger, logger *zap.Logger,
) UserService { ) UserService {
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀 // CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
@@ -50,6 +57,7 @@ func NewUserService(
cache: cacheManager, cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""), cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager), cacheInv: database.NewCacheInvalidator(cacheManager),
storage: storageClient,
logger: logger, logger: logger,
} }
} }
@@ -347,6 +355,67 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains) return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
} }
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
// 验证文件大小
fileSize := len(fileData)
const minSize = 512 // 512B
const maxSize = 5 * 1024 * 1024 // 5MB
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
return "", fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
}
// 验证文件扩展名
ext := strings.ToLower(filepath.Ext(fileName))
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
if !allowedExts[ext] {
return "", fmt.Errorf("不支持的文件格式: %s仅支持 jpg/jpeg/png/gif/webp", ext)
}
// 检查存储服务
if s.storage == nil {
return "", errors.New("存储服务不可用")
}
// 计算文件哈希
hashBytes := sha256.Sum256(fileData)
hash := hex.EncodeToString(hashBytes[:])
// 获取存储桶
bucketName, err := s.storage.GetBucket("avatars")
if err != nil {
return "", fmt.Errorf("获取存储桶失败: %w", err)
}
// 生成对象路径: avatars/{hash[:2]}/{hash[2:4]}/{hash}{ext}
objectName := fmt.Sprintf("%s/%s/%s%s", hash[:2], hash[2:4], hash, ext)
// 上传文件
reader := bytes.NewReader(fileData)
contentType := "image/" + strings.TrimPrefix(ext, ".")
if ext == ".jpg" {
contentType = "image/jpeg"
}
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
return "", fmt.Errorf("上传文件失败: %w", err)
}
// 构建文件URL
avatarURL := s.storage.BuildFileURL(bucketName, objectName)
// 更新用户头像
if err := s.UpdateAvatar(ctx, userID, avatarURL); err != nil {
return "", fmt.Errorf("更新用户头像失败: %w", err)
}
s.logger.Info("上传头像成功",
zap.Int64("user_id", userID),
zap.String("hash", hash),
zap.String("url", avatarURL),
)
return avatarURL, nil
}
func (s *userService) GetMaxProfilesPerUser() int { func (s *userService) GetMaxProfilesPerUser() int {
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user") config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
if err != nil || config == nil { if err != nil || config == nil {

View File

@@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) {
// 初始化Service // 初始化Service
// 注意redisClient 和 cacheManager 传入 nil因为 Register 方法中没有使用它们 // 注意redisClient 和 cacheManager 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -130,7 +130,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, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -211,7 +211,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, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -259,7 +259,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, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -294,7 +294,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, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -322,7 +322,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, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
logger := zap.NewNop() logger := zap.NewNop()
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
// 未配置时走默认值 // 未配置时走默认值
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, 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)
} }

View File

@@ -35,7 +35,7 @@ type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50" example:"newuser"` Username string `json:"username" binding:"required,min=3,max=50" example:"newuser"`
Email string `json:"email" binding:"required,email" example:"user@example.com"` Email string `json:"email" binding:"required,email" example:"user@example.com"`
Password string `json:"password" binding:"required,min=6,max=128" example:"password123"` Password string `json:"password" binding:"required,min=6,max=128" example:"password123"`
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码 VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码
Avatar string `json:"avatar" binding:"omitempty,url" example:"https://rustfs.example.com/avatars/user_1/avatar.png"` // 可选,用户自定义头像 Avatar string `json:"avatar" binding:"omitempty,url" example:"https://rustfs.example.com/avatars/user_1/avatar.png"` // 可选,用户自定义头像
} }
@@ -65,19 +65,6 @@ type ChangeEmailRequest struct {
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
} }
// GenerateAvatarUploadURLRequest 生成头像上传URL请求
type GenerateAvatarUploadURLRequest struct {
FileName string `json:"file_name" binding:"required" example:"avatar.png"`
}
// GenerateAvatarUploadURLResponse 生成头像上传URL响应
type GenerateAvatarUploadURLResponse struct {
PostURL string `json:"post_url" example:"https://rustfs.example.com/avatars"`
FormData map[string]string `json:"form_data"`
AvatarURL string `json:"avatar_url" example:"https://rustfs.example.com/avatars/user_1/xxx.png"`
ExpiresIn int `json:"expires_in" example:"900"` // 秒
}
// CreateProfileRequest 创建档案请求 // CreateProfileRequest 创建档案请求
type CreateProfileRequest struct { type CreateProfileRequest struct {
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"` Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
@@ -90,20 +77,6 @@ type UpdateTextureRequest struct {
IsPublic *bool `json:"is_public" example:"true"` IsPublic *bool `json:"is_public" example:"true"`
} }
// GenerateTextureUploadURLRequest 生成材质上传URL请求
type GenerateTextureUploadURLRequest struct {
FileName string `json:"file_name" binding:"required" example:"skin.png"`
TextureType TextureType `json:"texture_type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
}
// GenerateTextureUploadURLResponse 生成材质上传URL响应
type GenerateTextureUploadURLResponse struct {
PostURL string `json:"post_url" example:"https://rustfs.example.com/textures"`
FormData map[string]string `json:"form_data"`
TextureURL string `json:"texture_url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
ExpiresIn int `json:"expires_in" example:"900"` // 秒
}
// LoginResponse 登录响应 // LoginResponse 登录响应
type LoginResponse struct { type LoginResponse struct {
Token string `json:"token"` Token string `json:"token"`
@@ -158,7 +131,6 @@ type ProfileInfo struct {
Name string `json:"name" example:"PlayerName"` Name string `json:"name" example:"PlayerName"`
SkinID *int64 `json:"skin_id,omitempty" example:"1"` SkinID *int64 `json:"skin_id,omitempty" example:"1"`
CapeID *int64 `json:"cape_id,omitempty" example:"2"` CapeID *int64 `json:"cape_id,omitempty" example:"2"`
IsActive bool `json:"is_active" example:"true"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2025-10-01T12:00:00Z"` LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2025-10-01T12:00:00Z"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"` CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"` UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
@@ -178,18 +150,6 @@ type UploadURLResponse struct {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
// CreateTextureRequest 创建材质请求
type CreateTextureRequest struct {
Name string `json:"name" binding:"required,min=1,max=100" example:"My Cool Skin"`
Description string `json:"description" binding:"max=500" example:"A very cool skin"`
Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
URL string `json:"url" binding:"required,url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
Hash string `json:"hash" binding:"required,len=64" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" binding:"required,min=1" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
IsSlim bool `json:"is_slim" example:"false"` // Alex模型(细臂)为trueSteve模型(粗臂)为false
}
// SearchTextureRequest 搜索材质请求 // SearchTextureRequest 搜索材质请求
type SearchTextureRequest struct { type SearchTextureRequest struct {
PaginationRequest PaginationRequest
@@ -212,4 +172,13 @@ type SystemConfigResponse struct {
RegistrationEnabled bool `json:"registration_enabled" example:"true"` RegistrationEnabled bool `json:"registration_enabled" example:"true"`
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"`
}

View File

@@ -38,3 +38,9 @@ func MustGetJWTService() *JWTService {
} }
return service return service
} }

View File

@@ -21,7 +21,6 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"` JWT JWTConfig `mapstructure:"jwt"`
Casbin CasbinConfig `mapstructure:"casbin"` Casbin CasbinConfig `mapstructure:"casbin"`
Log LogConfig `mapstructure:"log"` Log LogConfig `mapstructure:"log"`
Upload UploadConfig `mapstructure:"upload"`
Email EmailConfig `mapstructure:"email"` Email EmailConfig `mapstructure:"email"`
Security SecurityConfig `mapstructure:"security"` Security SecurityConfig `mapstructure:"security"`
} }
@@ -99,14 +98,6 @@ type LogConfig struct {
Compress bool `mapstructure:"compress"` Compress bool `mapstructure:"compress"`
} }
// UploadConfig 文件上传配置
type UploadConfig struct {
MaxSize int64 `mapstructure:"max_size"`
AllowedTypes []string `mapstructure:"allowed_types"`
TextureMaxSize int64 `mapstructure:"texture_max_size"`
AvatarMaxSize int64 `mapstructure:"avatar_max_size"`
}
// EmailConfig 邮件配置 // EmailConfig 邮件配置
type EmailConfig struct { type EmailConfig struct {
Enabled bool `mapstructure:"enabled"` Enabled bool `mapstructure:"enabled"`
@@ -203,12 +194,6 @@ func setDefaults() {
viper.SetDefault("log.max_age", 28) viper.SetDefault("log.max_age", 28)
viper.SetDefault("log.compress", true) viper.SetDefault("log.compress", true)
// 文件上传默认配置
viper.SetDefault("upload.max_size", 10485760)
viper.SetDefault("upload.texture_max_size", 2097152)
viper.SetDefault("upload.avatar_max_size", 1048576)
viper.SetDefault("upload.allowed_types", []string{"image/png", "image/jpeg"})
// 邮件默认配置 // 邮件默认配置
viper.SetDefault("email.enabled", false) viper.SetDefault("email.enabled", false)
viper.SetDefault("email.smtp_port", 587) viper.SetDefault("email.smtp_port", 587)
@@ -370,25 +355,6 @@ func overrideFromEnv(config *Config) {
} }
} }
// 处理文件上传配置
if maxSize := os.Getenv("UPLOAD_MAX_SIZE"); maxSize != "" {
if val, err := strconv.ParseInt(maxSize, 10, 64); err == nil {
config.Upload.MaxSize = val
}
}
if textureMaxSize := os.Getenv("UPLOAD_TEXTURE_MAX_SIZE"); textureMaxSize != "" {
if val, err := strconv.ParseInt(textureMaxSize, 10, 64); err == nil {
config.Upload.TextureMaxSize = val
}
}
if avatarMaxSize := os.Getenv("UPLOAD_AVATAR_MAX_SIZE"); avatarMaxSize != "" {
if val, err := strconv.ParseInt(avatarMaxSize, 10, 64); err == nil {
config.Upload.AvatarMaxSize = val
}
}
// 处理邮件配置 // 处理邮件配置
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" { if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1" config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"

View File

@@ -63,3 +63,9 @@ func MustGetRustFSConfig() *RustFSConfig {
} }

View File

@@ -306,6 +306,11 @@ 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)

View File

@@ -99,3 +99,9 @@ func GetDSN(cfg config.DatabaseConfig) string {
cfg.Timezone, cfg.Timezone,
) )
} }

View File

@@ -46,3 +46,9 @@ func MustGetService() *Service {

View File

@@ -49,3 +49,9 @@ func MustGetLogger() *zap.Logger {

View File

@@ -49,3 +49,9 @@ func MustGetClient() *Client {

View File

@@ -47,3 +47,9 @@ func MustGetClient() *StorageClient {

View File

@@ -173,18 +173,34 @@ func (s *StorageClient) GetObject(ctx context.Context, bucketName, objectName st
} }
// ParseFileURL 从文件URL中解析出bucket和objectName // ParseFileURL 从文件URL中解析出bucket和objectName
// URL格式: {publicURL}/{bucket}/{objectName} // URL格式: {publicURL}/{bucket}/{objectName}[?query],自动忽略查询参数
func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) { func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) {
// 移除 publicURL 前缀 u, err := url.Parse(fileURL)
if !strings.HasPrefix(fileURL, s.publicURL) { if err != nil {
return "", "", fmt.Errorf("URL解析失败: %w", err)
}
// 校验前缀(协议+主机+端口)
public, err := url.Parse(s.publicURL)
if err != nil {
return "", "", fmt.Errorf("publicURL解析失败: %w", err)
}
if u.Scheme != public.Scheme || u.Host != public.Host {
return "", "", fmt.Errorf("URL格式不正确必须以 %s 开头", s.publicURL) return "", "", fmt.Errorf("URL格式不正确必须以 %s 开头", s.publicURL)
} }
// 移除 publicURL 前缀开头的 / // 去掉前缀开头的斜杠,仅使用路径部分,不包含 query
path := strings.TrimPrefix(fileURL, s.publicURL) path := strings.TrimPrefix(u.Path, "/")
path = strings.TrimPrefix(path, "/")
// 解析路径 // 如果 publicURL 自带路径前缀,移除该前缀
pubPath := strings.TrimPrefix(public.Path, "/")
if pubPath != "" {
if !strings.HasPrefix(path, pubPath) {
return "", "", fmt.Errorf("URL格式不正确缺少前缀 %s", public.Path)
}
path = strings.TrimPrefix(path, pubPath)
path = strings.TrimPrefix(path, "/")
}
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 { if len(parts) < 2 {
return "", "", fmt.Errorf("URL格式不正确无法解析bucket和objectName") return "", "", fmt.Errorf("URL格式不正确无法解析bucket和objectName")
@@ -194,8 +210,7 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string,
objectName = parts[1] objectName = parts[1]
// URL解码 objectName // URL解码 objectName
decoded, err := url.PathUnescape(objectName) if decoded, decErr := url.PathUnescape(objectName); decErr == nil {
if err == nil {
objectName = decoded objectName = decoded
} }

42
run.bat
View File

@@ -1,42 +0,0 @@
@echo off
chcp 65001 >nul
echo ================================
echo CarrotSkin Backend Server
echo ================================
echo.
echo [1/3] Checking swag tool...
where swag >nul 2>nul
if %ERRORLEVEL% NEQ 0 (
echo [WARN] swag tool not found, installing...
go install github.com/swaggo/swag/cmd/swag@latest
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Failed to install swag
echo Please install manually: go install github.com/swaggo/swag/cmd/swag@latest
pause
exit /b 1
)
echo [OK] swag tool installed
) else (
echo [OK] swag tool found
)
echo.
echo [2/3] Generating Swagger documentation...
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Failed to generate Swagger docs
pause
exit /b 1
)
echo [OK] Swagger docs generated
echo.
echo [3/3] Starting server...
echo Server: http://localhost:8080
echo Swagger: http://localhost:8080/swagger/index.html
echo Health: http://localhost:8080/health
echo.
echo Press Ctrl+C to stop server
echo.
go run cmd/server/main.go

36
run.sh
View File

@@ -1,36 +0,0 @@
#!/bin/bash
echo "================================"
echo " CarrotSkin Backend Server"
echo "================================"
echo ""
echo "[1/3] 检查swag工具..."
if ! command -v swag &> /dev/null; then
echo "[警告] swag工具未安装正在安装..."
go install github.com/swaggo/swag/cmd/swag@latest
if [ $? -ne 0 ]; then
echo "[错误] swag安装失败请手动安装: go install github.com/swaggo/swag/cmd/swag@latest"
exit 1
fi
echo "[成功] swag工具安装完成"
else
echo "[成功] swag工具已安装"
fi
echo ""
echo "[2/3] 生成Swagger API文档..."
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
if [ $? -ne 0 ]; then
echo "[错误] Swagger文档生成失败"
exit 1
fi
echo "[成功] Swagger文档生成完成"
echo ""
echo "[3/3] 启动服务器..."
echo "服务地址: http://localhost:8080"
echo "Swagger文档: http://localhost:8080/swagger/index.html"
echo "按 Ctrl+C 停止服务"
echo ""
go run cmd/server/main.go

View File

@@ -10,9 +10,12 @@ REQUIRED_VARS=(
"DATABASE_USERNAME" "DATABASE_USERNAME"
"DATABASE_PASSWORD" "DATABASE_PASSWORD"
"DATABASE_NAME" "DATABASE_NAME"
"REDIS_HOST"
"RUSTFS_ENDPOINT" "RUSTFS_ENDPOINT"
"RUSTFS_ACCESS_KEY" "RUSTFS_ACCESS_KEY"
"RUSTFS_SECRET_KEY" "RUSTFS_SECRET_KEY"
"RUSTFS_BUCKET_TEXTURES"
"RUSTFS_BUCKET_AVATARS"
"JWT_SECRET" "JWT_SECRET"
) )
@@ -26,7 +29,9 @@ fi
echo "✅ .env 文件存在" echo "✅ .env 文件存在"
# 加载.env文件 # 加载.env文件
set -a
source .env 2>/dev/null source .env 2>/dev/null
set +a
# 检查必需的环境变量 # 检查必需的环境变量
missing_vars=() missing_vars=()
@@ -51,8 +56,10 @@ echo "✅ 所有必需的环境变量都已设置"
# 检查关键配置的合理性 # 检查关键配置的合理性
echo "" echo ""
echo "📋 当前配置概览:" echo "📋 当前配置概览:"
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME" echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:${DATABASE_PORT:-5432}/$DATABASE_NAME"
echo " Redis: $REDIS_HOST:${REDIS_PORT:-6379}"
echo " RustFS: $RUSTFS_ENDPOINT" echo " RustFS: $RUSTFS_ENDPOINT"
echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS"
echo " JWT密钥长度: ${#JWT_SECRET} 字符" echo " JWT密钥长度: ${#JWT_SECRET} 字符"
# 检查JWT密钥长度 # 检查JWT密钥长度
@@ -65,11 +72,11 @@ if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
echo "⚠️ 使用的是默认JWT密钥生产环境中请更改" echo "⚠️ 使用的是默认JWT密钥生产环境中请更改"
fi fi
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ]; then if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ] || [ "$DATABASE_PASSWORD" = "carrotskin123" ]; then
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改" echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
fi fi
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ]; then if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ] || [ "$RUSTFS_ACCESS_KEY" = "rustfsadmin" ]; then
echo "⚠️ 使用的是默认RustFS凭证生产环境中请更改" echo "⚠️ 使用的是默认RustFS凭证生产环境中请更改"
fi fi

View File

@@ -1,28 +0,0 @@
#!/bin/bash
# CarrotSkin 开发环境启动脚本
echo "🚀 启动 CarrotSkin 开发环境..."
# 检查配置文件
if [ ! -f "configs/config.yaml" ]; then
echo "📝 复制配置文件..."
cp configs/config.yaml.example configs/config.yaml
echo "⚠️ 请编辑 configs/config.yaml 文件配置数据库和其他服务连接信息"
fi
# 检查依赖
echo "📦 检查依赖..."
go mod tidy
# 生成Swagger文档
echo "📚 生成Swagger文档..."
if command -v swag &> /dev/null; then
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
else
echo "⚠️ swag工具未安装请运行: go install github.com/swaggo/swag/cmd/swag@latest"
fi
# 启动应用
echo "🎯 启动应用..."
go run cmd/server/main.go