3 Commits

Author SHA1 Message Date
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
46 changed files with 1308 additions and 1376 deletions

View File

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

View File

@@ -23,6 +23,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
DATABASE_MAX_IDLE_CONNS=10
DATABASE_MAX_OPEN_CONNS=100
DATABASE_CONN_MAX_LIFETIME=1h
DATABASE_CONN_MAX_IDLE_TIME=10m
# =============================================================================
# Redis配置
@@ -37,6 +38,7 @@ REDIS_POOL_SIZE=10
# RustFS对象存储配置 (S3兼容)
# =============================================================================
RUSTFS_ENDPOINT=127.0.0.1:9000
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=your_access_key
RUSTFS_SECRET_KEY=your_secret_key
RUSTFS_USE_SSL=false
@@ -55,26 +57,17 @@ JWT_EXPIRE_HOURS=168
LOG_LEVEL=info
LOG_FORMAT=json
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
LOGIN_LOCK_DURATION=30m
# CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# =============================================================================
# 邮件配置(可选)
# 邮件配置
# 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465
# =============================================================================
EMAIL_ENABLED=false

3
.gitignore vendored
View File

@@ -23,8 +23,8 @@ dist/
build/
# Compiled binaries
/server
server.exe
main.exe
# IDE files
.vscode/
@@ -108,3 +108,4 @@ local/
dev/
service_coverage
.gitignore
docs/

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
import (
@@ -22,6 +31,8 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
_ "carrotskin/docs" // Swagger docs
)
func main() {

View File

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

28
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.2
require (
github.com/chai2010/webp v1.4.0
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1
@@ -12,6 +13,9 @@ require (
github.com/minio/minio-go/v7 v7.0.97
github.com/redis/go-redis/v9 v9.17.2
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/v2 v2.0.4
go.uber.org/zap v1.27.1
@@ -22,22 +26,32 @@ require (
require (
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/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/goccy/go-yaml v1.18.0 // indirect
github.com/goccy/go-yaml v1.19.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect

66
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -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/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
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/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
@@ -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/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
@@ -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
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=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
@@ -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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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
// Service层
UserService service.UserService
ProfileService service.ProfileService
TextureService service.TextureService
TokenService service.TokenService
YggdrasilService service.YggdrasilService
VerificationService service.VerificationService
UploadService service.UploadService
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
UserService service.UserService
ProfileService service.ProfileService
TextureService service.TextureService
TokenService service.TokenService
YggdrasilService service.YggdrasilService
VerificationService service.VerificationService
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
TextureRenderService service.TextureRenderService
}
// NewContainer 创建依赖容器
@@ -86,9 +86,10 @@ func NewContainer(
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化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.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT
@@ -105,7 +106,6 @@ func NewContainer(
// 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient)
c.UploadService = service.NewUploadService(storageClient)
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化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 设置安全服务
func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types"
"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 获取材质详情
func (h *TextureHandler) Get(c *gin.Context) {
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 更新材质
func (h *TextureHandler) Update(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)

View File

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

View File

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

View File

@@ -7,12 +7,11 @@ import (
// Profile Minecraft 档案模型
type Profile struct {
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 角色名
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"`
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"`
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"`
@@ -33,7 +32,6 @@ type ProfileResponse struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Textures ProfileTexturesData `json:"textures"`
IsActive bool `json:"is_active"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -35,7 +35,6 @@ type ProfileRepository interface {
Delete(ctx context.Context, uuid string) error
BatchDelete(ctx context.Context, uuids []string) (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
GetByNames(ctx context.Context, names []string) ([]*model.Profile, 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
}
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 {
return r.db.WithContext(ctx).Model(&model.Profile{}).
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 测试统计逻辑
func TestProfileRepository_CountLogic(t *testing.T) {
tests := []struct {
@@ -109,30 +74,30 @@ func TestProfileRepository_CountLogic(t *testing.T) {
// TestProfileRepository_UpdateFieldsLogic 测试更新字段逻辑
func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
tests := []struct {
name string
uuid string
updates map[string]interface{}
name string
uuid string
updates map[string]interface{}
wantValid bool
}{
{
name: "有效的更新",
uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{
"name": "NewName",
"name": "NewName",
"skin_id": int64(1),
},
wantValid: true,
},
{
name: "UUID为空",
uuid: "",
updates: map[string]interface{}{"name": "NewName"},
name: "UUID为空",
uuid: "",
updates: map[string]interface{}{"name": "NewName"},
wantValid: false,
},
{
name: "更新字段为空",
uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{},
name: "更新字段为空",
uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{},
wantValid: true, // 空更新也是有效的,只是不会更新任何字段
},
}
@@ -150,24 +115,24 @@ func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
// TestProfileRepository_FindOneProfileLogic 测试查找单个档案的逻辑
func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
tests := []struct {
name string
name string
profileCount int
wantError bool
wantError bool
}{
{
name: "有档案时返回第一个",
name: "有档案时返回第一个",
profileCount: 1,
wantError: false,
wantError: false,
},
{
name: "多个档案时返回第一个",
name: "多个档案时返回第一个",
profileCount: 3,
wantError: false,
wantError: false,
},
{
name: "没有档案时应该错误",
name: "没有档案时应该错误",
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 {
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
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
// 头像上传
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
// URL验证
ValidateAvatarURL(ctx context.Context, avatarURL string) error
@@ -45,7 +48,6 @@ type ProfileService interface {
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
// 批量查询
@@ -56,8 +58,7 @@ type ProfileService interface {
// TextureService 材质服务接口
type TextureService interface {
// 材质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)
GetByHash(ctx context.Context, hash string) (*model.Texture, 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)
}
// 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服务接口
type YggdrasilService interface {
// 用户认证
@@ -141,6 +136,69 @@ type SecurityService interface {
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
}
// TextureRenderService 纹理渲染服务接口
type TextureRenderService interface {
// RenderTexture 渲染纹理为预览图
RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error)
// RenderTextureFromData 从原始数据渲染纹理
RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error)
// GetRenderURL 获取渲染图的URL
GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string
// DeleteRenderCache 删除渲染缓存
DeleteRenderCache(ctx context.Context, textureID int64) error
// RenderAvatar 渲染头像支持2D/3D模式
RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error)
// RenderCape 渲染披风
RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
// RenderPreview 渲染预览图类似Blessing Skin的preview功能
RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error)
}
// RenderType 渲染类型
type RenderType string
const (
RenderTypeFront RenderType = "front" // 正面
RenderTypeBack RenderType = "back" // 背面
RenderTypeFull RenderType = "full" // 全身
RenderTypeHead RenderType = "head" // 头像
RenderTypeIsometric RenderType = "isometric" // 等距视图
)
// ImageFormat 输出格式
type ImageFormat string
const (
ImageFormatPNG ImageFormat = "png"
ImageFormatWEBP ImageFormat = "webp"
)
// AvatarMode 头像模式
type AvatarMode string
const (
AvatarMode2D AvatarMode = "2d" // 2D头像
AvatarMode3D AvatarMode = "3d" // 3D头像
)
// TextureType 纹理类型
type TextureType string
const (
TextureTypeSteve TextureType = "steve" // Steve皮肤
TextureTypeAlex TextureType = "alex" // Alex皮肤
TextureTypeCape TextureType = "cape" // 披风
)
// RenderResult 渲染结果(附带缓存/HTTP头信息
type RenderResult struct {
URL string
ContentType string
ETag string
LastModified time.Time
Size int64
}
// Services 服务集合
type Services struct {
User UserService
@@ -149,7 +207,6 @@ type Services struct {
Token TokenService
Verification VerificationService
Captcha CaptchaService
Upload UploadService
Yggdrasil YggdrasilService
Security SecurityService
}

View File

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

View File

@@ -77,18 +77,12 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
UserID: userID,
Name: name,
RSAPrivateKey: privateKey,
IsActive: true,
}
if err := s.profileRepo.Create(ctx, profile); err != nil {
return nil, fmt.Errorf("创建档案失败: %w", err)
}
// 设置活跃状态
if err := s.profileRepo.SetActive(ctx, profileUUID, userID); err != nil {
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
}
// 清除用户的 profile 列表缓存
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
}
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 {
count, err := s.profileRepo.CountByUserID(ctx, userID)
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的权限检查逻辑
func TestUpdateProfile_PermissionCheck(t *testing.T) {
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数量限制检查逻辑
func TestCheckProfileLimit_Logic(t *testing.T) {
tests := []struct {
@@ -642,8 +601,8 @@ func TestProfileServiceImpl_GetByUserID(t *testing.T) {
}
}
// TestProfileServiceImpl_Update_And_SetActive 测试 Update 与 SetActive
func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) {
// TestProfileServiceImpl_Update 测试 Update
func TestProfileServiceImpl_Update(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
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 {
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

View File

@@ -0,0 +1,701 @@
package service
import (
"bytes"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/storage"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"net/http"
"time"
"github.com/chai2010/webp"
"go.uber.org/zap"
)
// textureRenderService TextureRenderService的实现
type textureRenderService struct {
textureRepo repository.TextureRepository
storage *storage.StorageClient
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
logger *zap.Logger
}
// NewTextureRenderService 创建TextureRenderService实例
func NewTextureRenderService(
textureRepo repository.TextureRepository,
storageClient *storage.StorageClient,
cacheManager *database.CacheManager,
logger *zap.Logger,
) TextureRenderService {
return &textureRenderService{
textureRepo: textureRepo,
storage: storageClient,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
logger: logger,
}
}
// RenderTexture 渲染纹理为预览图
func (s *textureRenderService) RenderTexture(ctx context.Context, textureID int64, renderType RenderType, size int, format ImageFormat) (*RenderResult, error) {
// 参数验证
if size <= 0 || size > 2048 {
return nil, errors.New("渲染尺寸必须在1到2048之间")
}
contentType, err := normalizeFormat(format)
if err != nil {
return nil, err
}
// 检查缓存(包含格式)
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size)
var cached RenderResult
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
return &cached, nil
}
// 获取纹理信息
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return nil, fmt.Errorf("获取纹理失败: %w", err)
}
if texture == nil {
return nil, errors.New("纹理不存在")
}
// 从对象存储获取纹理文件
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
if err != nil {
return nil, fmt.Errorf("下载纹理失败: %w", err)
}
// 渲染纹理
renderedImage, _, err := s.RenderTextureFromData(ctx, textureData, renderType, size, format, texture.IsSlim)
if err != nil {
return nil, fmt.Errorf("渲染纹理失败: %w", err)
}
// 保存渲染结果到对象存储
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, renderType, size, format, renderedImage, contentType)
if err != nil {
return nil, fmt.Errorf("保存渲染结果失败: %w", err)
}
// 若源对象有元信息,透传 LastModified/ETag 作为参考
if srcInfo != nil {
if result.LastModified.IsZero() {
result.LastModified = srcInfo.LastModified
}
if result.ETag == "" {
result.ETag = srcInfo.ETag
}
}
// 缓存结果1小时
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
s.logger.Warn("缓存渲染结果失败", zap.Error(err))
}
return result, nil
}
// RenderAvatar 渲染头像支持2D/3D模式
func (s *textureRenderService) RenderAvatar(ctx context.Context, textureID int64, size int, mode AvatarMode, format ImageFormat) (*RenderResult, error) {
if size <= 0 || size > 1024 {
return nil, errors.New("头像渲染尺寸必须在1到1024之间")
}
contentType, err := normalizeFormat(format)
if err != nil {
return nil, err
}
renderKey := fmt.Sprintf("avatar-%s", mode)
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderKey, format), size)
var cached RenderResult
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
return &cached, nil
}
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return nil, fmt.Errorf("获取纹理失败: %w", err)
}
if texture == nil {
return nil, errors.New("纹理不存在")
}
if texture.Type != model.TextureTypeSkin {
return nil, errors.New("仅皮肤纹理支持头像渲染")
}
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
if err != nil {
return nil, fmt.Errorf("下载纹理失败: %w", err)
}
img, err := png.Decode(bytes.NewReader(textureData))
if err != nil {
return nil, fmt.Errorf("解码PNG失败: %w", err)
}
var rendered image.Image
switch mode {
case AvatarMode3D:
rendered = s.renderIsometricView(img, texture.IsSlim, size)
default:
rendered = s.renderHeadView(img, size)
}
encoded, err := encodeImage(rendered, format)
if err != nil {
return nil, fmt.Errorf("编码渲染头像失败: %w", err)
}
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType(renderKey), size, format, encoded, contentType)
if err != nil {
return nil, fmt.Errorf("保存头像渲染失败: %w", err)
}
if srcInfo != nil && result.LastModified.IsZero() {
result.LastModified = srcInfo.LastModified
}
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
s.logger.Warn("缓存头像渲染失败", zap.Error(err))
}
return result, nil
}
// RenderCape 渲染披风
func (s *textureRenderService) RenderCape(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) {
if size <= 0 || size > 2048 {
return nil, errors.New("披风渲染尺寸必须在1到2048之间")
}
contentType, err := normalizeFormat(format)
if err != nil {
return nil, err
}
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("cape:%s", format), size)
var cached RenderResult
if err := s.cache.Get(ctx, cacheKey, &cached); err == nil && cached.URL != "" {
return &cached, nil
}
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return nil, fmt.Errorf("获取纹理失败: %w", err)
}
if texture == nil {
return nil, errors.New("纹理不存在")
}
if texture.Type != model.TextureTypeCape {
return nil, errors.New("仅披风纹理支持披风渲染")
}
textureData, srcInfo, err := s.downloadTexture(ctx, texture.URL)
if err != nil {
return nil, fmt.Errorf("下载纹理失败: %w", err)
}
img, err := png.Decode(bytes.NewReader(textureData))
if err != nil {
return nil, fmt.Errorf("解码PNG失败: %w", err)
}
rendered := s.renderCapeView(img, size)
encoded, err := encodeImage(rendered, format)
if err != nil {
return nil, fmt.Errorf("编码披风渲染失败: %w", err)
}
result, err := s.saveRenderToStorage(ctx, textureID, texture.Hash, RenderType("cape"), size, format, encoded, contentType)
if err != nil {
return nil, fmt.Errorf("保存披风渲染失败: %w", err)
}
if srcInfo != nil && result.LastModified.IsZero() {
result.LastModified = srcInfo.LastModified
}
if err := s.cache.Set(ctx, cacheKey, result, time.Hour); err != nil {
s.logger.Warn("缓存披风渲染失败", zap.Error(err))
}
return result, nil
}
// RenderPreview 渲染预览图(类似 Blessing Skin preview
func (s *textureRenderService) RenderPreview(ctx context.Context, textureID int64, size int, format ImageFormat) (*RenderResult, error) {
if size <= 0 || size > 2048 {
return nil, errors.New("预览渲染尺寸必须在1到2048之间")
}
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return nil, fmt.Errorf("获取纹理失败: %w", err)
}
if texture == nil {
return nil, errors.New("纹理不存在")
}
switch texture.Type {
case model.TextureTypeCape:
return s.RenderCape(ctx, textureID, size, format)
default:
// 使用改进的等距视图作为默认预览
return s.RenderTexture(ctx, textureID, RenderTypeIsometric, size, format)
}
}
// RenderTextureFromData 从原始数据渲染纹理
func (s *textureRenderService) RenderTextureFromData(ctx context.Context, textureData []byte, renderType RenderType, size int, format ImageFormat, isSlim bool) ([]byte, string, error) {
// 解码PNG图像
img, err := png.Decode(bytes.NewReader(textureData))
if err != nil {
return nil, "", fmt.Errorf("解码PNG失败: %w", err)
}
contentType, err := normalizeFormat(format)
if err != nil {
return nil, "", err
}
// 根据渲染类型处理图像
var renderedImage image.Image
switch renderType {
case RenderTypeFront:
renderedImage = s.renderFrontView(img, isSlim, size)
case RenderTypeBack:
renderedImage = s.renderBackView(img, isSlim, size)
case RenderTypeFull:
renderedImage = s.renderFullView(img, isSlim, size)
case RenderTypeHead:
renderedImage = s.renderHeadView(img, size)
case RenderTypeIsometric:
renderedImage = s.renderIsometricView(img, isSlim, size)
default:
return nil, "", errors.New("不支持的渲染类型")
}
encoded, err := encodeImage(renderedImage, format)
if err != nil {
return nil, "", fmt.Errorf("编码纹理失败: %w", err)
}
return encoded, contentType, nil
}
// GetRenderURL 获取渲染图的URL
func (s *textureRenderService) GetRenderURL(textureID int64, renderType RenderType, size int, format ImageFormat) string {
// 构建渲染图的存储路径
// 格式: renders/{textureID}/{renderType}/{size}.{ext}
ext := string(format)
if ext == "" {
ext = string(ImageFormatPNG)
}
return fmt.Sprintf("renders/%d/%s/%d.%s", textureID, renderType, size, ext)
}
// DeleteRenderCache 删除渲染缓存
func (s *textureRenderService) DeleteRenderCache(ctx context.Context, textureID int64) error {
// 删除所有渲染类型与格式的缓存
renderTypes := []RenderType{
RenderTypeFront, RenderTypeBack, RenderTypeFull, RenderTypeHead,
RenderTypeIsometric, RenderType("avatar-2d"), RenderType("avatar-3d"), RenderType("cape"),
}
formats := []ImageFormat{ImageFormatPNG, ImageFormatWEBP}
sizes := []int{64, 128, 256, 512}
for _, renderType := range renderTypes {
for _, size := range sizes {
for _, format := range formats {
cacheKey := s.cacheKeys.TextureRender(textureID, fmt.Sprintf("%s:%s", renderType, format), size)
if err := s.cache.Delete(ctx, cacheKey); err != nil {
s.logger.Warn("删除渲染缓存失败", zap.Error(err))
}
}
}
}
return nil
}
// downloadTexture 从对象存储下载纹理
func (s *textureRenderService) downloadTexture(ctx context.Context, textureURL string) ([]byte, *storage.ObjectInfo, error) {
// 先直接通过 HTTP GET 下载(对公有/匿名可读对象最兼容)
if resp, httpErr := http.Get(textureURL); httpErr == nil && resp != nil && resp.StatusCode == http.StatusOK {
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
if readErr == nil {
var lm time.Time
if t, parseErr := http.ParseTime(resp.Header.Get("Last-Modified")); parseErr == nil {
lm = t
}
return body, &storage.ObjectInfo{
Size: resp.ContentLength,
LastModified: lm,
ContentType: resp.Header.Get("Content-Type"),
ETag: resp.Header.Get("ETag"),
}, nil
}
}
// 若 HTTP 失败,再尝试通过对象存储 SDK 访问
bucket, objectName, err := s.storage.ParseFileURL(textureURL)
if err != nil {
return nil, nil, fmt.Errorf("解析纹理URL失败: %w", err)
}
reader, info, err := s.storage.GetObject(ctx, bucket, objectName)
if err != nil {
s.logger.Error("获取纹理对象失败",
zap.String("texture_url", textureURL),
zap.String("bucket", bucket),
zap.String("object", objectName),
zap.Error(err),
)
return nil, nil, fmt.Errorf("获取纹理对象失败: bucket=%s object=%s err=%v", bucket, objectName, err)
}
defer reader.Close()
data, readErr := io.ReadAll(reader)
if readErr != nil {
return nil, nil, readErr
}
return data, info, nil
}
// saveRenderToStorage 保存渲染结果到对象存储
func (s *textureRenderService) saveRenderToStorage(ctx context.Context, textureID int64, textureHash string, renderType RenderType, size int, format ImageFormat, imageData []byte, contentType string) (*RenderResult, error) {
// 获取存储桶
bucketName, err := s.storage.GetBucket("renders")
if err != nil {
// 如果renders桶不存在使用textures桶
bucketName, err = s.storage.GetBucket("textures")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
}
if len(textureHash) < 4 {
return nil, errors.New("纹理哈希长度不足,无法生成路径")
}
ext := string(format)
objectName := fmt.Sprintf("renders/%s/%s/%s_%s_%d.%s",
textureHash[:2], textureHash[2:4], textureHash, renderType, size, ext)
// 上传到对象存储
reader := bytes.NewReader(imageData)
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(len(imageData)), contentType); err != nil {
return nil, fmt.Errorf("上传渲染结果失败: %w", err)
}
etag := sha256.Sum256(imageData)
result := &RenderResult{
URL: s.storage.BuildFileURL(bucketName, objectName),
ContentType: contentType,
ETag: hex.EncodeToString(etag[:]),
LastModified: time.Now().UTC(),
Size: int64(len(imageData)),
}
return result, nil
}
// renderFrontView 渲染正面视图(分块+第二层,含 Alex/Steve
func (s *textureRenderService) renderFrontView(img image.Image, isSlim bool, size int) image.Image {
base := composeFrontModel(img, isSlim)
return scaleNearest(base, size, size)
}
// renderBackView 渲染背面视图(分块+第二层)
func (s *textureRenderService) renderBackView(img image.Image, isSlim bool, size int) image.Image {
base := composeBackModel(img, isSlim)
return scaleNearest(base, size, size)
}
// renderFullView 渲染全身视图(正面+背面)
func (s *textureRenderService) renderFullView(img image.Image, isSlim bool, size int) image.Image {
front := composeFrontModel(img, isSlim)
back := composeBackModel(img, isSlim)
full := image.NewRGBA(image.Rect(0, 0, front.Bounds().Dx()+back.Bounds().Dx(), front.Bounds().Dy()))
draw.Draw(full, image.Rect(0, 0, front.Bounds().Dx(), front.Bounds().Dy()), front, image.Point{}, draw.Src)
draw.Draw(full, image.Rect(front.Bounds().Dx(), 0, full.Bounds().Dx(), full.Bounds().Dy()), back, image.Point{}, draw.Src)
return scaleNearest(full, size*2, size)
}
// renderHeadView 渲染头像视图(包含第二层帽子)
func (s *textureRenderService) renderHeadView(img image.Image, size int) image.Image {
headBase := safeCrop(img, image.Rect(8, 8, 16, 16))
headOverlay := safeCrop(img, image.Rect(40, 8, 48, 16))
if headBase == nil {
// 返回空白头像
return scaleNearest(image.NewRGBA(image.Rect(0, 0, 8, 8)), size, size)
}
canvas := image.NewRGBA(image.Rect(0, 0, headBase.Bounds().Dx(), headBase.Bounds().Dy()))
draw.Draw(canvas, canvas.Bounds(), headBase, headBase.Bounds().Min, draw.Src)
if headOverlay != nil {
draw.Draw(canvas, canvas.Bounds(), headOverlay, headOverlay.Bounds().Min, draw.Over)
}
return scaleNearest(canvas, size, size)
}
// renderIsometricView 渲染等距视图(改进 3D
func (s *textureRenderService) renderIsometricView(img image.Image, isSlim bool, size int) image.Image {
result := image.NewRGBA(image.Rect(0, 0, size, size))
bgColor := color.RGBA{240, 240, 240, 255}
draw.Draw(result, result.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
front := scaleNearest(composeFrontModel(img, isSlim), size/2, size/2)
for y := 0; y < front.Bounds().Dy(); y++ {
for x := 0; x < front.Bounds().Dx(); x++ {
destX := x + size/4
destY := y + size/4
depth := float64(x) / float64(front.Bounds().Dx())
brightness := 1.0 - depth*0.25
c := front.At(x, y)
r, g, b, a := c.RGBA()
newR := uint32(float64(r) * brightness)
newG := uint32(float64(g) * brightness)
newB := uint32(float64(b) * brightness)
if a > 0 {
result.Set(destX, destY, color.RGBA64{
R: uint16(newR),
G: uint16(newG),
B: uint16(newB),
A: uint16(a),
})
}
}
}
borderColor := color.RGBA{200, 200, 200, 255}
for i := 0; i < 2; i++ {
drawLine(result, size/4, size/4, size*3/4, size/4, borderColor)
drawLine(result, size/4, size*3/4, size*3/4, size*3/4, borderColor)
drawLine(result, size/4, size/4, size/4, size*3/4, borderColor)
drawLine(result, size*3/4, size/4, size*3/4, size*3/4, borderColor)
}
return result
}
// drawLine 绘制直线
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
dx := abs(x2 - x1)
dy := abs(y2 - y1)
sx := -1
if x1 < x2 {
sx = 1
}
sy := -1
if y1 < y2 {
sy = 1
}
err := dx - dy
for {
img.Set(x1, y1, c)
if x1 == x2 && y1 == y2 {
break
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x1 += sx
}
if e2 < dx {
err += dx
y1 += sy
}
}
}
// abs 绝对值
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// renderCapeView 渲染披风(等比缩放)
func (s *textureRenderService) renderCapeView(img image.Image, size int) image.Image {
srcBounds := img.Bounds()
if srcBounds.Dx() == 0 || srcBounds.Dy() == 0 {
return img
}
targetWidth := size * 2
targetHeight := size
return scaleNearest(img, targetWidth, targetHeight)
}
// composeFrontModel 组合正面分块(含第二层)
func composeFrontModel(img image.Image, isSlim bool) *image.RGBA {
canvas := image.NewRGBA(image.Rect(0, 0, 16, 32))
armW := 4
if isSlim {
armW = 3
}
drawLayeredPart(canvas, image.Rect(4, 0, 12, 8),
safeCrop(img, image.Rect(8, 8, 16, 16)),
safeCrop(img, image.Rect(40, 8, 48, 16)))
drawLayeredPart(canvas, image.Rect(4, 8, 12, 20),
safeCrop(img, image.Rect(20, 20, 28, 32)),
safeCrop(img, image.Rect(20, 36, 28, 48)))
drawLayeredPart(canvas, image.Rect(0, 8, armW, 20),
safeCrop(img, image.Rect(44, 20, 48, 32)),
safeCrop(img, image.Rect(44, 36, 48, 48)))
drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20),
safeCrop(img, image.Rect(36, 52, 40, 64)),
safeCrop(img, image.Rect(52, 52, 56, 64)))
drawLayeredPart(canvas, image.Rect(4, 20, 8, 32),
safeCrop(img, image.Rect(4, 20, 8, 32)),
safeCrop(img, image.Rect(4, 36, 8, 48)))
drawLayeredPart(canvas, image.Rect(8, 20, 12, 32),
safeCrop(img, image.Rect(20, 52, 24, 64)),
safeCrop(img, image.Rect(4, 52, 8, 64)))
return canvas
}
// composeBackModel 组合背面分块(含第二层)
func composeBackModel(img image.Image, isSlim bool) *image.RGBA {
canvas := image.NewRGBA(image.Rect(0, 0, 16, 32))
armW := 4
if isSlim {
armW = 3
}
drawLayeredPart(canvas, image.Rect(4, 0, 12, 8),
safeCrop(img, image.Rect(24, 8, 32, 16)),
safeCrop(img, image.Rect(56, 8, 64, 16)))
drawLayeredPart(canvas, image.Rect(4, 8, 12, 20),
safeCrop(img, image.Rect(32, 20, 40, 32)),
safeCrop(img, image.Rect(32, 36, 40, 48)))
drawLayeredPart(canvas, image.Rect(0, 8, armW, 20),
safeCrop(img, image.Rect(52, 20, 56, 32)),
safeCrop(img, image.Rect(52, 36, 56, 48)))
drawLayeredPart(canvas, image.Rect(16-armW, 8, 16, 20),
safeCrop(img, image.Rect(44, 52, 48, 64)),
safeCrop(img, image.Rect(60, 52, 64, 64)))
drawLayeredPart(canvas, image.Rect(4, 20, 8, 32),
safeCrop(img, image.Rect(12, 20, 16, 32)),
safeCrop(img, image.Rect(12, 36, 16, 48)))
drawLayeredPart(canvas, image.Rect(8, 20, 12, 32),
safeCrop(img, image.Rect(28, 52, 32, 64)),
safeCrop(img, image.Rect(12, 52, 16, 64)))
return canvas
}
// drawLayeredPart 绘制单个分块(基础层+第二层)
func drawLayeredPart(dst draw.Image, dstRect image.Rectangle, base image.Image, overlay image.Image) {
if base == nil {
return
}
dstW := dstRect.Dx()
dstH := dstRect.Dy()
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
srcX := base.Bounds().Min.X + x*base.Bounds().Dx()/dstW
srcY := base.Bounds().Min.Y + y*base.Bounds().Dy()/dstH
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, base.At(srcX, srcY))
}
}
if overlay != nil {
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
srcX := overlay.Bounds().Min.X + x*overlay.Bounds().Dx()/dstW
srcY := overlay.Bounds().Min.Y + y*overlay.Bounds().Dy()/dstH
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, overlay.At(srcX, srcY))
}
}
}
}
// safeCrop 安全裁剪超界返回nil
func safeCrop(img image.Image, rect image.Rectangle) image.Image {
b := img.Bounds()
if rect.Min.X < 0 || rect.Min.Y < 0 || rect.Max.X > b.Max.X || rect.Max.Y > b.Max.Y {
return nil
}
subImg := image.NewRGBA(rect)
draw.Draw(subImg, rect, img, rect.Min, draw.Src)
return subImg
}
// scaleNearest 最近邻缩放
func scaleNearest(src image.Image, targetW, targetH int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
srcBounds := src.Bounds()
for y := 0; y < targetH; y++ {
for x := 0; x < targetW; x++ {
srcX := srcBounds.Min.X + x*srcBounds.Dx()/targetW
srcY := srcBounds.Min.Y + y*srcBounds.Dy()/targetH
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
// normalizeFormat 校验输出格式
func normalizeFormat(format ImageFormat) (string, error) {
if format == "" {
format = ImageFormatPNG
}
switch format {
case ImageFormatPNG:
return "image/png", nil
case ImageFormatWEBP:
return "image/webp", nil
default:
return "", fmt.Errorf("不支持的输出格式: %s", format)
}
}
// encodeImage 将图像编码为指定格式
func encodeImage(img image.Image, format ImageFormat) ([]byte, error) {
var buf bytes.Buffer
switch format {
case ImageFormatWEBP:
if err := webp.Encode(&buf, img, &webp.Options{Lossless: true}); err != nil {
return nil, err
}
default:
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}

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) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.Texture(id)

View File

@@ -478,129 +478,6 @@ func boolPtr(b bool) *bool {
// 使用 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
func TestTextureServiceImpl_GetByID(t *testing.T) {
textureRepo := NewMockTextureRepository()
@@ -617,7 +494,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
tests := []struct {
name string
@@ -675,7 +552,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
@@ -714,7 +591,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
_ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
@@ -764,7 +641,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
@@ -807,7 +684,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()

View File

@@ -203,10 +203,9 @@ func TestTokenServiceImpl_Create(t *testing.T) {
// 预置Profile
testProfile := &model.Profile{
UUID: "test-profile-uuid",
UserID: 1,
Name: "TestProfile",
IsActive: true,
UUID: "test-profile-uuid",
UserID: 1,
Name: "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
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
@@ -15,6 +19,7 @@ import (
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"go.uber.org/zap"
)
@@ -28,6 +33,7 @@ type userService struct {
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
storage *storage.StorageClient
logger *zap.Logger
}
@@ -38,6 +44,7 @@ func NewUserService(
jwtService *auth.JWTService,
redisClient *redis.Client,
cacheManager *database.CacheManager,
storageClient *storage.StorageClient,
logger *zap.Logger,
) UserService {
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
@@ -50,6 +57,7 @@ func NewUserService(
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
storage: storageClient,
logger: logger,
}
}
@@ -347,6 +355,67 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
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 {
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
if err != nil || config == nil {

View File

@@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) {
// 初始化Service
// 注意redisClient 和 cacheManager 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -130,7 +130,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -211,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -259,7 +259,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -294,7 +294,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -322,7 +322,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
_ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
logger := zap.NewNop()
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
@@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
// 未配置时走默认值
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 {
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"`
Email string `json:"email" binding:"required,email" example:"user@example.com"`
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"` // 可选,用户自定义头像
}
@@ -65,19 +65,6 @@ type ChangeEmailRequest struct {
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 创建档案请求
type CreateProfileRequest struct {
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"`
}
// 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 登录响应
type LoginResponse struct {
Token string `json:"token"`
@@ -158,7 +131,6 @@ type ProfileInfo struct {
Name string `json:"name" example:"PlayerName"`
SkinID *int64 `json:"skin_id,omitempty" example:"1"`
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"`
CreatedAt time.Time `json:"created_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"`
}
// 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 搜索材质请求
type SearchTextureRequest struct {
PaginationRequest
@@ -212,4 +172,13 @@ type SystemConfigResponse struct {
RegistrationEnabled bool `json:"registration_enabled" example:"true"`
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
}
}
// RenderResponse 材质渲染响应
type RenderResponse struct {
URL string `json:"url" example:"https://rustfs.example.com/renders/xxx.png"`
ContentType string `json:"content_type" example:"image/png"`
ETag string `json:"etag,omitempty" example:"abc123def456"`
Size int64 `json:"size" example:"2048"`
LastModified *time.Time `json:"last_modified,omitempty" example:"2025-10-01T12:00:00Z"`
}

View File

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

View File

@@ -21,7 +21,6 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
Casbin CasbinConfig `mapstructure:"casbin"`
Log LogConfig `mapstructure:"log"`
Upload UploadConfig `mapstructure:"upload"`
Email EmailConfig `mapstructure:"email"`
Security SecurityConfig `mapstructure:"security"`
}
@@ -99,14 +98,6 @@ type LogConfig struct {
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 邮件配置
type EmailConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -203,12 +194,6 @@ func setDefaults() {
viper.SetDefault("log.max_age", 28)
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.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 != "" {
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)
}
// TextureRender 构建材质渲染缓存键
func (b *CacheKeyBuilder) TextureRender(textureID int64, renderType string, size int) string {
return fmt.Sprintf("%stexture:render:%d:%s:%d", b.prefix, textureID, renderType, size)
}
// Token 构建令牌缓存键
func (b *CacheKeyBuilder) Token(accessToken string) string {
return fmt.Sprintf("%stoken:%s", b.prefix, accessToken)

View File

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

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_PASSWORD"
"DATABASE_NAME"
"REDIS_HOST"
"RUSTFS_ENDPOINT"
"RUSTFS_ACCESS_KEY"
"RUSTFS_SECRET_KEY"
"RUSTFS_BUCKET_TEXTURES"
"RUSTFS_BUCKET_AVATARS"
"JWT_SECRET"
)
@@ -26,7 +29,9 @@ fi
echo "✅ .env 文件存在"
# 加载.env文件
set -a
source .env 2>/dev/null
set +a
# 检查必需的环境变量
missing_vars=()
@@ -51,8 +56,10 @@ 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_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS"
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
# 检查JWT密钥长度
@@ -65,11 +72,11 @@ if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
echo "⚠️ 使用的是默认JWT密钥生产环境中请更改"
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 "⚠️ 使用的是默认数据库密码,生产环境中请更改"
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凭证生产环境中请更改"
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