4 Commits

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

View File

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

View File

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

4
.gitignore vendored
View File

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

74
Dockerfile Normal file
View File

@@ -0,0 +1,74 @@
# ==================== 构建阶段 ====================
FROM golang:latest AS builder
# 安装构建依赖
RUN apk add --no-cache git ca-certificates tzdata
# 设置工作目录
WORKDIR /build
# 复制依赖文件
COPY go.mod go.sum ./
# 配置 Go 代理并下载依赖
ENV GOPROXY=https://goproxy.cn,direct
RUN go mod download
# 复制源代码
COPY . .
# 构建应用
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s -X main.Version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev')" \
-o server ./cmd/server
# ==================== 运行阶段 ====================
FROM alpine:3.19
# 安装运行时依赖
RUN apk add --no-cache ca-certificates tzdata
# 设置时区
ENV TZ=Asia/Shanghai
# 创建非 root 用户
RUN adduser -D -g '' appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /build/server .
# 复制配置文件目录结构
COPY --from=builder /build/configs ./configs
# 设置文件权限
RUN chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
# 启动应用
ENTRYPOINT ["./server"]

View File

@@ -1,3 +1,12 @@
// @title CarrotSkin API
// @version 1.0
// @description Minecraft皮肤站后端API
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
package main package main
import ( import (
@@ -12,7 +21,6 @@ import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/handler" "carrotskin/internal/handler"
"carrotskin/internal/middleware" "carrotskin/internal/middleware"
"carrotskin/internal/task"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config" "carrotskin/pkg/config"
"carrotskin/pkg/database" "carrotskin/pkg/database"
@@ -23,6 +31,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
_ "carrotskin/docs" // Swagger docs
) )
func main() { func main() {
@@ -60,18 +70,11 @@ func main() {
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err)) loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
} }
// 初始化Redis(开发/测试环境失败时会自动回退到miniredis // 初始化Redis
if err := redis.Init(cfg.Redis, loggerInstance); err != nil { if err := redis.Init(cfg.Redis, loggerInstance); err != nil {
loggerInstance.Fatal("Redis初始化失败", zap.Error(err)) loggerInstance.Fatal("Redis连接失败", zap.Error(err))
}
defer redis.Close()
// 记录Redis模式
if redis.IsUsingMiniRedis() {
loggerInstance.Info("使用miniredis进行开发/测试")
} else {
loggerInstance.Info("使用生产Redis")
} }
defer redis.MustGetClient().Close()
// 初始化对象存储 (RustFS - S3兼容) // 初始化对象存储 (RustFS - S3兼容)
var storageClient *storage.StorageClient var storageClient *storage.StorageClient
@@ -118,13 +121,6 @@ func main() {
// 使用依赖注入方式注册路由 // 使用依赖注入方式注册路由
handler.RegisterRoutesWithDI(router, c) handler.RegisterRoutesWithDI(router, c)
// 启动后台任务Token已迁移到Redis不再需要清理任务
// 如需使用数据库Token存储可以恢复TokenCleanupTask
taskRunner := task.NewRunner(loggerInstance)
taskCtx, taskCancel := context.WithCancel(context.Background())
defer taskCancel()
taskRunner.Start(taskCtx)
// 创建HTTP服务器 // 创建HTTP服务器
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Server.Port, Addr: cfg.Server.Port,
@@ -147,10 +143,6 @@ func main() {
<-quit <-quit
loggerInstance.Info("正在关闭服务器...") loggerInstance.Info("正在关闭服务器...")
// 停止后台任务
taskCancel()
taskRunner.Wait()
// 设置关闭超时 // 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()

View File

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

33
go.mod
View File

@@ -5,7 +5,7 @@ go 1.24.0
toolchain go1.24.2 toolchain go1.24.2
require ( require (
github.com/alicebob/miniredis/v2 v2.31.1 github.com/chai2010/webp v1.4.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@@ -13,36 +13,45 @@ require (
github.com/minio/minio-go/v7 v7.0.97 github.com/minio/minio-go/v7 v7.0.97
github.com/redis/go-redis/v9 v9.17.2 github.com/redis/go-redis/v9 v9.17.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/wenlng/go-captcha-assets v1.0.7 github.com/wenlng/go-captcha-assets v1.0.7
github.com/wenlng/go-captcha/v2 v2.0.4 github.com/wenlng/go-captcha/v2 v2.0.4
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.19.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/crc64nvme v1.1.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect go.uber.org/mock v0.6.0 // indirect
github.com/yuin/gopher-lua v1.1.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/image v0.33.0 // indirect golang.org/x/image v0.33.0 // indirect
golang.org/x/mod v0.30.0 // indirect golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.18.0 // indirect

78
go.sum
View File

@@ -1,10 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -17,9 +14,8 @@ github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2N
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -35,12 +31,41 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -55,8 +80,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
@@ -65,7 +90,6 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -108,8 +132,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
@@ -125,10 +149,10 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -159,8 +183,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -170,12 +200,10 @@ github.com/wenlng/go-captcha-assets v1.0.7/go.mod h1:zinRACsdYcL/S6pHgI9Iv7FKTU4
github.com/wenlng/go-captcha/v2 v2.0.4 h1:5cSUF36ZyA03qeDMjKmeXGpbYJMXEexZIYK3Vga3ME0= github.com/wenlng/go-captcha/v2 v2.0.4 h1:5cSUF36ZyA03qeDMjKmeXGpbYJMXEexZIYK3Vga3ME0=
github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34= github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
@@ -199,6 +227,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -206,7 +235,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -226,6 +254,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -29,21 +29,22 @@ type Container struct {
UserRepo repository.UserRepository UserRepo repository.UserRepository
ProfileRepo repository.ProfileRepository ProfileRepo repository.ProfileRepository
TextureRepo repository.TextureRepository TextureRepo repository.TextureRepository
TokenRepo repository.TokenRepository
ClientRepo repository.ClientRepository ClientRepo repository.ClientRepository
ConfigRepo repository.SystemConfigRepository ConfigRepo repository.SystemConfigRepository
YggdrasilRepo repository.YggdrasilRepository YggdrasilRepo repository.YggdrasilRepository
// Service层 // Service层
UserService service.UserService UserService service.UserService
ProfileService service.ProfileService ProfileService service.ProfileService
TextureService service.TextureService TextureService service.TextureService
TokenService service.TokenService TokenService service.TokenService
YggdrasilService service.YggdrasilService YggdrasilService service.YggdrasilService
VerificationService service.VerificationService VerificationService service.VerificationService
UploadService service.UploadService SecurityService service.SecurityService
SecurityService service.SecurityService CaptchaService service.CaptchaService
CaptchaService service.CaptchaService SignatureService *service.SignatureService
SignatureService *service.SignatureService TextureRenderService service.TextureRenderService
} }
// NewContainer 创建依赖容器 // NewContainer 创建依赖容器
@@ -60,14 +61,6 @@ func NewContainer(
Prefix: "carrotskin:", Prefix: "carrotskin:",
Expiration: 5 * time.Minute, Expiration: 5 * time.Minute,
Enabled: true, Enabled: true,
Policy: database.CachePolicy{
UserTTL: 5 * time.Minute,
UserEmailTTL: 5 * time.Minute,
ProfileTTL: 5 * time.Minute,
ProfileListTTL: 3 * time.Minute,
TextureTTL: 5 * time.Minute,
TextureListTTL: 2 * time.Minute,
},
}) })
c := &Container{ c := &Container{
@@ -83,6 +76,7 @@ func NewContainer(
c.UserRepo = repository.NewUserRepository(db) c.UserRepo = repository.NewUserRepository(db)
c.ProfileRepo = repository.NewProfileRepository(db) c.ProfileRepo = repository.NewProfileRepository(db)
c.TextureRepo = repository.NewTextureRepository(db) c.TextureRepo = repository.NewTextureRepository(db)
c.TokenRepo = repository.NewTokenRepository(db)
c.ClientRepo = repository.NewClientRepository(db) c.ClientRepo = repository.NewClientRepository(db)
c.ConfigRepo = repository.NewSystemConfigRepository(db) c.ConfigRepo = repository.NewSystemConfigRepository(db)
c.YggdrasilRepo = repository.NewYggdrasilRepository(db) c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
@@ -92,9 +86,10 @@ func NewContainer(
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger) c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化Service注入缓存管理器 // 初始化Service注入缓存管理器
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger) c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger)
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger) c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger) c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要 // 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT // 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT
@@ -104,28 +99,13 @@ func NewContainer(
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err)) logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
} }
yggdrasilJWT := auth.NewYggdrasilJWTService(privateKey, "carrotskin") yggdrasilJWT := auth.NewYggdrasilJWTService(privateKey, "carrotskin")
c.TokenService = service.NewTokenServiceJWT(c.TokenRepo, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger)
// 创建Redis Token存储必须使用Redis包括miniredis回退
if redisClient == nil {
logger.Fatal("Redis客户端未初始化无法创建Token服务")
}
tokenStore := auth.NewTokenStoreRedis(
redisClient,
logger,
auth.WithKeyPrefix("token:"),
auth.WithDefaultTTL(24*time.Hour),
auth.WithStaleTTL(30*24*time.Hour),
auth.WithMaxTokensPerUser(10),
)
c.TokenService = service.NewTokenServiceRedis(tokenStore, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger)
// 使用组合服务(内部包含认证、会话、序列化、证书服务) // 使用组合服务(内部包含认证、会话、序列化、证书服务)
c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger, c.TokenService) c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.TokenRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger)
// 初始化其他服务 // 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient) c.SecurityService = service.NewSecurityService(redisClient)
c.UploadService = service.NewUploadService(storageClient)
c.CaptchaService = service.NewCaptchaService(redisClient, logger) c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化VerificationService需要email.Service // 初始化VerificationService需要email.Service
@@ -206,6 +186,13 @@ func WithTextureRepo(repo repository.TextureRepository) Option {
} }
} }
// WithTokenRepo 设置令牌仓储
func WithTokenRepo(repo repository.TokenRepository) Option {
return func(c *Container) {
c.TokenRepo = repo
}
}
// WithConfigRepo 设置系统配置仓储 // WithConfigRepo 设置系统配置仓储
func WithConfigRepo(repo repository.SystemConfigRepository) Option { func WithConfigRepo(repo repository.SystemConfigRepository) Option {
return func(c *Container) { return func(c *Container) {
@@ -262,13 +249,6 @@ func WithVerificationService(svc service.VerificationService) Option {
} }
} }
// WithUploadService 设置上传服务
func WithUploadService(svc service.UploadService) Option {
return func(c *Container) {
c.UploadService = svc
}
}
// WithSecurityService 设置安全服务 // WithSecurityService 设置安全服务
func WithSecurityService(svc service.SecurityService) Option { func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) { return func(c *Container) {

View File

@@ -1,38 +0,0 @@
package errors
import (
"errors"
"testing"
)
func TestAppErrorBasics(t *testing.T) {
root := errors.New("root")
appErr := NewBadRequest("bad", root)
if appErr.Code != 400 || appErr.Message != "bad" {
t.Fatalf("unexpected appErr fields: %+v", appErr)
}
if got := appErr.Error(); got != "bad: root" {
t.Fatalf("unexpected Error(): %s", got)
}
if !Is(appErr, root) {
t.Fatalf("Is should match wrapped error")
}
var target *AppError
if !As(appErr, &target) {
t.Fatalf("As should succeed")
}
}
func TestWrap(t *testing.T) {
if Wrap(nil, "msg") != nil {
t.Fatalf("Wrap nil should return nil")
}
err := errors.New("base")
wrapped := Wrap(err, "ctx")
if wrapped.Error() != "ctx: base" {
t.Fatalf("wrap message mismatch: %v", wrapped)
}
}

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ import (
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
) )
// Handlers 集中管理所有Handler // Handlers 集中管理所有Handler
@@ -38,6 +40,9 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由 // 健康检查路由
router.GET("/health", HealthCheck) router.GET("/health", HealthCheck)
// Swagger文档路由
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 创建Handler实例 // 创建Handler实例
h := NewHandlers(c) h := NewHandlers(c)
@@ -90,8 +95,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW
userGroup.PUT("/profile", h.UpdateProfile) userGroup.PUT("/profile", h.UpdateProfile)
// 头像相关 // 头像相关
userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL) userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
userGroup.PUT("/avatar", h.UpdateAvatar) userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL外部URL
// 更换邮箱 // 更换邮箱
userGroup.POST("/change-email", h.ChangeEmail) userGroup.POST("/change-email", h.ChangeEmail)
@@ -108,14 +113,16 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
// 公开路由(无需认证) // 公开路由(无需认证)
textureGroup.GET("", h.Search) textureGroup.GET("", h.Search)
textureGroup.GET("/:id", h.Get) textureGroup.GET("/:id", h.Get)
textureGroup.GET("/:id/render", h.RenderTexture) // type/front/back/full/head/isometric
textureGroup.GET("/:id/avatar", h.RenderAvatar) // mode=2d/3d
textureGroup.GET("/:id/cape", h.RenderCape)
textureGroup.GET("/:id/preview", h.RenderPreview) // 自动根据类型预览
// 需要认证的路由 // 需要认证的路由
textureAuth := textureGroup.Group("") textureAuth := textureGroup.Group("")
textureAuth.Use(middleware.AuthMiddleware(jwtService)) textureAuth.Use(middleware.AuthMiddleware(jwtService))
{ {
textureAuth.POST("/upload", h.Upload) // 直接上传文件 textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL保留兼容性
textureAuth.POST("", h.Create) // 创建材质记录配合预签名URL使用
textureAuth.PUT("/:id", h.Update) textureAuth.PUT("/:id", h.Update)
textureAuth.DELETE("/:id", h.Delete) textureAuth.DELETE("/:id", h.Delete)
textureAuth.POST("/:id/favorite", h.ToggleFavorite) textureAuth.POST("/:id/favorite", h.ToggleFavorite)
@@ -143,7 +150,6 @@ func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtServ
profileAuth.GET("/", h.List) profileAuth.GET("/", h.List)
profileAuth.PUT("/:uuid", h.Update) profileAuth.PUT("/:uuid", h.Update)
profileAuth.DELETE("/:uuid", h.Delete) profileAuth.DELETE("/:uuid", h.Delete)
profileAuth.POST("/:uuid/activate", h.SetActive)
} }
} }
} }

View File

@@ -1,27 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// 仅验证降级路径(未初始化依赖时的响应)
func TestHealthCheck_Degraded(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health", HealthCheck)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when dependencies missing, got %d", w.Code)
}
}

View File

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

View File

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

View File

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

View File

@@ -29,10 +29,3 @@ func (Client) TableName() string {

View File

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

23
internal/model/token.go Normal file
View File

@@ -0,0 +1,23 @@
package model
import "time"
// Token Yggdrasil 认证令牌模型
type Token struct {
AccessToken string `gorm:"column:access_token;type:text;primaryKey" json:"access_token"` // 改为text以支持JWT长度
UserID int64 `gorm:"column:user_id;not null;index:idx_tokens_user_id" json:"user_id"`
ClientToken string `gorm:"column:client_token;type:varchar(64);not null;index:idx_tokens_client_token" json:"client_token"`
ProfileId string `gorm:"column:profile_id;type:varchar(36);index:idx_tokens_profile_id" json:"profile_id"` // 改为可空
Version int `gorm:"column:version;not null;default:0;index:idx_tokens_version" json:"version"` // 新增:版本号
Usable bool `gorm:"column:usable;not null;default:true;index:idx_tokens_usable" json:"usable"`
IssueDate time.Time `gorm:"column:issue_date;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_tokens_issue_date,sort:desc" json:"issue_date"`
ExpiresAt *time.Time `gorm:"column:expires_at;type:timestamp" json:"expires_at,omitempty"` // 新增:过期时间
StaleAt *time.Time `gorm:"column:stale_at;type:timestamp" json:"stale_at,omitempty"` // 新增:过期但可用时间
// 关联
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Profile *Profile `gorm:"foreignKey:ProfileId;references:UUID;constraint:OnDelete:CASCADE" json:"profile,omitempty"`
}
// TableName 指定表名
func (Token) TableName() string { return "tokens" }

View File

@@ -1,18 +0,0 @@
package model
import (
"strings"
"testing"
)
func TestGenerateRandomPassword(t *testing.T) {
pwd := GenerateRandomPassword(16)
if len(pwd) != 16 {
t.Fatalf("length mismatch: %d", len(pwd))
}
for _, ch := range pwd {
if !strings.ContainsRune(passwordChars, ch) {
t.Fatalf("unexpected char: %c", ch)
}
}
}

View File

@@ -35,7 +35,6 @@ type ProfileRepository interface {
Delete(ctx context.Context, uuid string) error Delete(ctx context.Context, uuid string) error
BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除 BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除
CountByUserID(ctx context.Context, userID int64) (int64, error) CountByUserID(ctx context.Context, userID int64) (int64, error)
SetActive(ctx context.Context, uuid string, userID int64) error
UpdateLastUsedAt(ctx context.Context, uuid string) error UpdateLastUsedAt(ctx context.Context, uuid string) error
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error) GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error)
@@ -67,6 +66,18 @@ type TextureRepository interface {
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error) CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
} }
// TokenRepository 令牌仓储接口
type TokenRepository interface {
Create(ctx context.Context, token *model.Token) error
FindByAccessToken(ctx context.Context, accessToken string) (*model.Token, error)
GetByUserID(ctx context.Context, userId int64) ([]*model.Token, error)
GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error)
GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error)
DeleteByAccessToken(ctx context.Context, accessToken string) error
DeleteByUserID(ctx context.Context, userId int64) error
BatchDelete(ctx context.Context, accessTokens []string) (int64, error)
}
// SystemConfigRepository 系统配置仓储接口 // SystemConfigRepository 系统配置仓储接口
type SystemConfigRepository interface { type SystemConfigRepository interface {
GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)

View File

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

View File

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

View File

@@ -1,278 +0,0 @@
package repository
import (
"context"
"testing"
"carrotskin/internal/model"
"carrotskin/internal/testutil"
)
func TestUserRepository_BasicAndPoints(t *testing.T) {
db := testutil.NewTestDB(t)
repo := NewUserRepository(db)
ctx := context.Background()
user := &model.User{Username: "u1", Email: "e1@test.com", Password: "pwd", Status: 1}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("create user err: %v", err)
}
if u, err := repo.FindByID(ctx, user.ID); err != nil || u.Username != "u1" {
t.Fatalf("FindByID mismatch: %v %+v", err, u)
}
if u, err := repo.FindByUsername(ctx, "u1"); err != nil || u.Email != "e1@test.com" {
t.Fatalf("FindByUsername mismatch")
}
if u, err := repo.FindByEmail(ctx, "e1@test.com"); err != nil || u.ID != user.ID {
t.Fatalf("FindByEmail mismatch")
}
if err := repo.UpdateFields(ctx, user.ID, map[string]interface{}{"avatar": "a.png"}); err != nil {
t.Fatalf("UpdateFields err: %v", err)
}
if _, err := repo.BatchUpdate(ctx, []int64{user.ID}, map[string]interface{}{"status": 2}); err != nil {
t.Fatalf("BatchUpdate err: %v", err)
}
// 积分增加
if err := repo.UpdatePoints(ctx, user.ID, 10, "add", "bonus"); err != nil {
t.Fatalf("UpdatePoints add err: %v", err)
}
// 积分不足场景
if err := repo.UpdatePoints(ctx, user.ID, -100, "sub", "penalty"); err == nil {
t.Fatalf("expected insufficient points error")
}
if list, err := repo.FindByIDs(ctx, []int64{user.ID}); err != nil || len(list) != 1 {
t.Fatalf("FindByIDs mismatch: %v %d", err, len(list))
}
if list, err := repo.FindByIDs(ctx, []int64{}); err != nil || len(list) != 0 {
t.Fatalf("FindByIDs empty mismatch: %v %d", err, len(list))
}
// 软删除
if err := repo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete err: %v", err)
}
deleted, _ := repo.FindByID(ctx, user.ID)
if deleted != nil {
t.Fatalf("expected deleted user filtered out")
}
// 批量操作边界
if _, err := repo.BatchUpdate(ctx, []int64{}, map[string]interface{}{"status": 1}); err != nil {
t.Fatalf("BatchUpdate empty should not error: %v", err)
}
if _, err := repo.BatchDelete(ctx, []int64{}); err != nil {
t.Fatalf("BatchDelete empty should not error: %v", err)
}
// 日志写入
_ = repo.CreateLoginLog(ctx, &model.UserLoginLog{UserID: user.ID, IPAddress: "127.0.0.1"})
_ = repo.CreatePointLog(ctx, &model.UserPointLog{UserID: user.ID, Amount: 1, ChangeType: "add"})
}
func TestProfileRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
userRepo := NewUserRepository(db)
profileRepo := NewProfileRepository(db)
ctx := context.Background()
u := &model.User{Username: "u2", Email: "u2@test.com", Password: "pwd", Status: 1}
_ = userRepo.Create(ctx, u)
p := &model.Profile{UUID: "p-uuid", UserID: u.ID, Name: "hero", IsActive: false}
if err := profileRepo.Create(ctx, p); err != nil {
t.Fatalf("create profile err: %v", err)
}
if got, err := profileRepo.FindByUUID(ctx, "p-uuid"); err != nil || got.Name != "hero" {
t.Fatalf("FindByUUID mismatch: %v %+v", err, got)
}
if list, err := profileRepo.FindByUserID(ctx, u.ID); err != nil || len(list) != 1 {
t.Fatalf("FindByUserID mismatch")
}
if count, err := profileRepo.CountByUserID(ctx, u.ID); err != nil || count != 1 {
t.Fatalf("CountByUserID mismatch: %d err=%v", count, err)
}
if err := profileRepo.SetActive(ctx, "p-uuid", u.ID); err != nil {
t.Fatalf("SetActive err: %v", err)
}
if err := profileRepo.UpdateLastUsedAt(ctx, "p-uuid"); err != nil {
t.Fatalf("UpdateLastUsedAt err: %v", err)
}
if got, err := profileRepo.FindByName(ctx, "hero"); err != nil || got == nil {
t.Fatalf("FindByName mismatch")
}
if list, err := profileRepo.FindByUUIDs(ctx, []string{"p-uuid"}); err != nil || len(list) != 1 {
t.Fatalf("FindByUUIDs mismatch")
}
if _, err := profileRepo.BatchUpdate(ctx, []string{"p-uuid"}, map[string]interface{}{"name": "hero2"}); err != nil {
t.Fatalf("BatchUpdate profile err: %v", err)
}
if err := profileRepo.Delete(ctx, "p-uuid"); err != nil {
t.Fatalf("Delete err: %v", err)
}
if _, err := profileRepo.BatchDelete(ctx, []string{}); err != nil {
t.Fatalf("BatchDelete empty err: %v", err)
}
}
func TestTextureRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
userRepo := NewUserRepository(db)
textureRepo := NewTextureRepository(db)
ctx := context.Background()
u := &model.User{Username: "u3", Email: "u3@test.com", Password: "pwd", Status: 1}
_ = userRepo.Create(ctx, u)
tex := &model.Texture{
UploaderID: u.ID,
Name: "tex",
Hash: "hash1",
URL: "url1",
Type: model.TextureTypeSkin,
IsPublic: true,
Status: 1,
}
if err := textureRepo.Create(ctx, tex); err != nil {
t.Fatalf("create texture err: %v", err)
}
if got, _ := textureRepo.FindByHash(ctx, "hash1"); got == nil || got.ID != tex.ID {
t.Fatalf("FindByHash mismatch")
}
if got, _ := textureRepo.FindByHashAndUploaderID(ctx, "hash1", u.ID); got == nil {
t.Fatalf("FindByHashAndUploaderID mismatch")
}
_ = textureRepo.IncrementFavoriteCount(ctx, tex.ID)
_ = textureRepo.DecrementFavoriteCount(ctx, tex.ID)
_ = textureRepo.IncrementDownloadCount(ctx, tex.ID)
_ = textureRepo.CreateDownloadLog(ctx, &model.TextureDownloadLog{TextureID: tex.ID, UserID: &u.ID, IPAddress: "127.0.0.1"})
// 收藏
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID)
if fav, err := textureRepo.IsFavorited(ctx, u.ID, tex.ID); err == nil {
if !fav {
t.Fatalf("IsFavorited expected true")
}
} else {
t.Skipf("IsFavorited not supported by sqlite: %v", err)
}
_ = textureRepo.RemoveFavorite(ctx, u.ID, tex.ID)
// 批量更新与删除
if affected, err := textureRepo.BatchUpdate(ctx, []int64{tex.ID}, map[string]interface{}{"name": "tex-new"}); err != nil || affected != 1 {
t.Fatalf("BatchUpdate mismatch, affected=%d err=%v", affected, err)
}
if affected, err := textureRepo.BatchDelete(ctx, []int64{tex.ID}); err != nil || affected != 1 {
t.Fatalf("BatchDelete mismatch, affected=%d err=%v", affected, err)
}
// 搜索与收藏列表
_ = textureRepo.Create(ctx, &model.Texture{
UploaderID: u.ID,
Name: "search-me",
Hash: "hash2",
URL: "url2",
Type: model.TextureTypeCape,
IsPublic: true,
Status: 1,
})
if list, total, err := textureRepo.Search(ctx, "search", model.TextureTypeCape, true, 1, 10); err != nil || total == 0 || len(list) == 0 {
t.Fatalf("Search mismatch, total=%d len=%d err=%v", total, len(list), err)
}
_ = textureRepo.AddFavorite(ctx, u.ID, tex.ID+1)
if favList, total, err := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10); err != nil || total == 0 || len(favList) == 0 {
t.Fatalf("GetUserFavorites mismatch, total=%d len=%d err=%v", total, len(favList), err)
}
if _, total, err := textureRepo.Search(ctx, "", model.TextureTypeSkin, true, 1, 10); err != nil || total < 2 {
t.Fatalf("Search fallback mismatch")
}
// 列表与计数
if _, total, err := textureRepo.FindByUploaderID(ctx, u.ID, 1, 10); err != nil || total != 1 {
t.Fatalf("FindByUploaderID mismatch")
}
if cnt, err := textureRepo.CountByUploaderID(ctx, u.ID); err != nil || cnt != 1 {
t.Fatalf("CountByUploaderID mismatch")
}
_ = textureRepo.Delete(ctx, tex.ID)
}
func TestSystemConfigRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
repo := NewSystemConfigRepository(db)
ctx := context.Background()
cfg := &model.SystemConfig{Key: "site_name", Value: "Carrot", IsPublic: true}
if err := repo.Update(ctx, cfg); err != nil {
t.Fatalf("Update err: %v", err)
}
if v, err := repo.GetByKey(ctx, "site_name"); err != nil || v.Value != "Carrot" {
t.Fatalf("GetByKey mismatch")
}
_ = repo.UpdateValue(ctx, "site_name", "Carrot2")
if list, _ := repo.GetPublic(ctx); len(list) == 0 {
t.Fatalf("GetPublic expected entries")
}
if all, _ := repo.GetAll(ctx); len(all) == 0 {
t.Fatalf("GetAll expected entries")
}
if v, _ := repo.GetByKey(ctx, "site_name"); v.Value != "Carrot2" {
t.Fatalf("UpdateValue not applied")
}
}
func TestClientRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
repo := NewClientRepository(db)
ctx := context.Background()
client := &model.Client{UUID: "c-uuid", ClientToken: "ct-1", UserID: 9, Version: 1}
if err := repo.Create(ctx, client); err != nil {
t.Fatalf("Create client err: %v", err)
}
if got, _ := repo.FindByClientToken(ctx, "ct-1"); got == nil || got.UUID != "c-uuid" {
t.Fatalf("FindByClientToken mismatch")
}
if got, _ := repo.FindByUUID(ctx, "c-uuid"); got == nil || got.ClientToken != "ct-1" {
t.Fatalf("FindByUUID mismatch")
}
if list, _ := repo.FindByUserID(ctx, 9); len(list) != 1 {
t.Fatalf("FindByUserID mismatch")
}
_ = repo.IncrementVersion(ctx, "c-uuid")
updated, _ := repo.FindByUUID(ctx, "c-uuid")
if updated.Version != 2 {
t.Fatalf("IncrementVersion not applied, got %d", updated.Version)
}
_ = repo.DeleteByClientToken(ctx, "ct-1")
_ = repo.DeleteByUserID(ctx, 9)
}
func TestYggdrasilRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
userRepo := NewUserRepository(db)
yggRepo := NewYggdrasilRepository(db)
ctx := context.Background()
user := &model.User{Username: "u-ygg", Email: "ygg@test.com", Password: "pwd", Status: 1}
_ = userRepo.Create(ctx, user) // AfterCreate 会生成 yggdrasil 记录
pwd, err := yggRepo.GetPasswordByID(ctx, user.ID)
if err != nil || pwd == "" {
t.Fatalf("GetPasswordByID err=%v pwd=%s", err, pwd)
}
if err := yggRepo.ResetPassword(ctx, user.ID, "newpwd"); err != nil {
t.Fatalf("ResetPassword err: %v", err)
}
}

View File

@@ -0,0 +1,71 @@
package repository
import (
"carrotskin/internal/model"
"context"
"gorm.io/gorm"
)
// tokenRepository TokenRepository的实现
type tokenRepository struct {
db *gorm.DB
}
// NewTokenRepository 创建TokenRepository实例
func NewTokenRepository(db *gorm.DB) TokenRepository {
return &tokenRepository{db: db}
}
func (r *tokenRepository) Create(ctx context.Context, token *model.Token) error {
return r.db.WithContext(ctx).Create(token).Error
}
func (r *tokenRepository) FindByAccessToken(ctx context.Context, accessToken string) (*model.Token, error) {
var token model.Token
err := r.db.WithContext(ctx).Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return nil, err
}
return &token, nil
}
func (r *tokenRepository) GetByUserID(ctx context.Context, userId int64) ([]*model.Token, error) {
var tokens []*model.Token
err := r.db.WithContext(ctx).Where("user_id = ?", userId).Find(&tokens).Error
return tokens, err
}
func (r *tokenRepository) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
var token model.Token
err := r.db.WithContext(ctx).Select("profile_id").Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return "", err
}
return token.ProfileId, nil
}
func (r *tokenRepository) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
var token model.Token
err := r.db.WithContext(ctx).Select("user_id").Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return 0, err
}
return token.UserID, nil
}
func (r *tokenRepository) DeleteByAccessToken(ctx context.Context, accessToken string) error {
return r.db.WithContext(ctx).Where("access_token = ?", accessToken).Delete(&model.Token{}).Error
}
func (r *tokenRepository) DeleteByUserID(ctx context.Context, userId int64) error {
return r.db.WithContext(ctx).Where("user_id = ?", userId).Delete(&model.Token{}).Error
}
func (r *tokenRepository) BatchDelete(ctx context.Context, accessTokens []string) (int64, error) {
if len(accessTokens) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Where("access_token IN ?", accessTokens).Delete(&model.Token{})
return result.RowsAffected, result.Error
}

View File

@@ -0,0 +1,123 @@
package repository
import (
"testing"
)
// TestTokenRepository_BatchDeleteLogic 测试批量删除逻辑
func TestTokenRepository_BatchDeleteLogic(t *testing.T) {
tests := []struct {
name string
tokensToDelete []string
wantCount int64
wantError bool
}{
{
name: "有效的token列表",
tokensToDelete: []string{"token1", "token2", "token3"},
wantCount: 3,
wantError: false,
},
{
name: "空列表应该返回0",
tokensToDelete: []string{},
wantCount: 0,
wantError: false,
},
{
name: "单个token",
tokensToDelete: []string{"token1"},
wantCount: 1,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证批量删除逻辑空列表应该直接返回0
if len(tt.tokensToDelete) == 0 {
if tt.wantCount != 0 {
t.Errorf("Empty list should return count 0, got %d", tt.wantCount)
}
}
})
}
}
// TestTokenRepository_QueryConditions 测试token查询条件逻辑
func TestTokenRepository_QueryConditions(t *testing.T) {
tests := []struct {
name string
accessToken string
userID int64
wantValid bool
}{
{
name: "有效的access token",
accessToken: "valid-token-123",
userID: 1,
wantValid: true,
},
{
name: "access token为空",
accessToken: "",
userID: 1,
wantValid: false,
},
{
name: "用户ID为0",
accessToken: "valid-token-123",
userID: 0,
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.accessToken != "" && tt.userID > 0
if isValid != tt.wantValid {
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestTokenRepository_FindTokenByIDLogic 测试根据ID查找token的逻辑
func TestTokenRepository_FindTokenByIDLogic(t *testing.T) {
tests := []struct {
name string
accessToken string
resultCount int
wantError bool
}{
{
name: "找到token",
accessToken: "token-123",
resultCount: 1,
wantError: false,
},
{
name: "未找到token",
accessToken: "token-123",
resultCount: 0,
wantError: true, // 访问索引0会panic
},
{
name: "找到多个token异常情况",
accessToken: "token-123",
resultCount: 2,
wantError: false, // 返回第一个
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证逻辑如果结果为空访问索引0会出错
hasError := tt.resultCount == 0
if hasError != tt.wantError {
t.Errorf("FindTokenByID logic failed: got error=%v, want error=%v", hasError, tt.wantError)
}
})
}
}

View File

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

View File

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

View File

@@ -214,10 +214,6 @@ func (m *MockProfileRepository) CountByUserID(ctx context.Context, userID int64)
return int64(len(m.userProfiles[userID])), nil return int64(len(m.userProfiles[userID])), nil
} }
func (m *MockProfileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
return nil
}
func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error { func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
return nil return nil
} }
@@ -474,6 +470,101 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
return deleted, nil return deleted, nil
} }
// MockTokenRepository 模拟TokenRepository
type MockTokenRepository struct {
tokens map[string]*model.Token
userTokens map[int64][]*model.Token
FailCreate bool
FailFind bool
FailDelete bool
}
func NewMockTokenRepository() *MockTokenRepository {
return &MockTokenRepository{
tokens: make(map[string]*model.Token),
userTokens: make(map[int64][]*model.Token),
}
}
func (m *MockTokenRepository) Create(ctx context.Context, token *model.Token) error {
if m.FailCreate {
return errors.New("mock create error")
}
m.tokens[token.AccessToken] = token
m.userTokens[token.UserID] = append(m.userTokens[token.UserID], token)
return nil
}
func (m *MockTokenRepository) FindByAccessToken(ctx context.Context, accessToken string) (*model.Token, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
if token, ok := m.tokens[accessToken]; ok {
return token, nil
}
return nil, errors.New("token not found")
}
func (m *MockTokenRepository) GetByUserID(ctx context.Context, userId int64) ([]*model.Token, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
return m.userTokens[userId], nil
}
func (m *MockTokenRepository) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
if m.FailFind {
return "", errors.New("mock find error")
}
if token, ok := m.tokens[accessToken]; ok {
return token.ProfileId, nil
}
return "", errors.New("token not found")
}
func (m *MockTokenRepository) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
if m.FailFind {
return 0, errors.New("mock find error")
}
if token, ok := m.tokens[accessToken]; ok {
return token.UserID, nil
}
return 0, errors.New("token not found")
}
func (m *MockTokenRepository) DeleteByAccessToken(ctx context.Context, accessToken string) error {
if m.FailDelete {
return errors.New("mock delete error")
}
delete(m.tokens, accessToken)
return nil
}
func (m *MockTokenRepository) DeleteByUserID(ctx context.Context, userId int64) error {
if m.FailDelete {
return errors.New("mock delete error")
}
for _, token := range m.userTokens[userId] {
delete(m.tokens, token.AccessToken)
}
m.userTokens[userId] = nil
return nil
}
func (m *MockTokenRepository) BatchDelete(ctx context.Context, accessTokens []string) (int64, error) {
if m.FailDelete {
return 0, errors.New("mock delete error")
}
var count int64
for _, accessToken := range accessTokens {
if _, ok := m.tokens[accessToken]; ok {
delete(m.tokens, accessToken)
count++
}
}
return count, nil
}
// MockSystemConfigRepository 模拟SystemConfigRepository // MockSystemConfigRepository 模拟SystemConfigRepository
type MockSystemConfigRepository struct { type MockSystemConfigRepository struct {
configs map[string]*model.SystemConfig configs map[string]*model.SystemConfig
@@ -713,10 +804,6 @@ func (m *MockProfileService) Delete(uuid string, userID int64) error {
return nil return nil
} }
func (m *MockProfileService) SetActive(uuid string, userID int64) error {
return nil
}
func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error { func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error {
count := 0 count := 0
for _, profile := range m.profiles { for _, profile := range m.profiles {
@@ -873,11 +960,90 @@ func (m *MockTextureService) CheckUploadLimit(uploaderID int64, maxTextures int)
return nil return nil
} }
// MockTokenService 模拟TokenService
type MockTokenService struct {
tokens map[string]*model.Token
FailCreate bool
FailValidate bool
FailRefresh bool
}
func NewMockTokenService() *MockTokenService {
return &MockTokenService{
tokens: make(map[string]*model.Token),
}
}
func (m *MockTokenService) Create(userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
if m.FailCreate {
return nil, nil, "", "", errors.New("mock create error")
}
accessToken := "mock-access-token"
if clientToken == "" {
clientToken = "mock-client-token"
}
token := &model.Token{
AccessToken: accessToken,
ClientToken: clientToken,
UserID: userID,
ProfileId: uuid,
Usable: true,
}
m.tokens[accessToken] = token
return nil, nil, accessToken, clientToken, nil
}
func (m *MockTokenService) Validate(accessToken, clientToken string) bool {
if m.FailValidate {
return false
}
if token, ok := m.tokens[accessToken]; ok {
if clientToken == "" || token.ClientToken == clientToken {
return token.Usable
}
}
return false
}
func (m *MockTokenService) Refresh(accessToken, clientToken, selectedProfileID string) (string, string, error) {
if m.FailRefresh {
return "", "", errors.New("mock refresh error")
}
return "new-access-token", clientToken, nil
}
func (m *MockTokenService) Invalidate(accessToken string) {
delete(m.tokens, accessToken)
}
func (m *MockTokenService) InvalidateUserTokens(userID int64) {
for key, token := range m.tokens {
if token.UserID == userID {
delete(m.tokens, key)
}
}
}
func (m *MockTokenService) GetUUIDByAccessToken(accessToken string) (string, error) {
if token, ok := m.tokens[accessToken]; ok {
return token.ProfileId, nil
}
return "", errors.New("token not found")
}
func (m *MockTokenService) GetUserIDByAccessToken(accessToken string) (int64, error) {
if token, ok := m.tokens[accessToken]; ok {
return token.UserID, nil
}
return 0, errors.New("token not found")
}
// ============================================================================ // ============================================================================
// CacheManager Mock - 使用 database.CacheManager 的内存版本 // CacheManager Mock - uses database.CacheManager with nil redis
// ============================================================================ // ============================================================================
// NewMockCacheManager 创建一个内存 CacheManager 用于测试 // NewMockCacheManager 创建一个禁用的 CacheManager 用于测试
// 通过设置 Enabled = false缓存操作会被跳过测试不依赖 Redis
func NewMockCacheManager() *database.CacheManager { func NewMockCacheManager() *database.CacheManager {
return database.NewCacheManager(nil, database.CacheConfig{ return database.NewCacheManager(nil, database.CacheConfig{
Prefix: "test:", Prefix: "test:",

View File

@@ -11,6 +11,7 @@ import (
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
@@ -76,18 +77,12 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
UserID: userID, UserID: userID,
Name: name, Name: name,
RSAPrivateKey: privateKey, RSAPrivateKey: privateKey,
IsActive: true,
} }
if err := s.profileRepo.Create(ctx, profile); err != nil { if err := s.profileRepo.Create(ctx, profile); err != nil {
return nil, fmt.Errorf("创建档案失败: %w", err) return nil, fmt.Errorf("创建档案失败: %w", err)
} }
// 设置活跃状态
if err := s.profileRepo.SetActive(ctx, profileUUID, userID); err != nil {
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
}
// 清除用户的 profile 列表缓存 // 清除用户的 profile 列表缓存
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID)) s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
@@ -98,7 +93,7 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
// 尝试从缓存获取 // 尝试从缓存获取
cacheKey := s.cacheKeys.Profile(uuid) cacheKey := s.cacheKeys.Profile(uuid)
var profile model.Profile var profile model.Profile
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profile); ok { if err := s.cache.Get(ctx, cacheKey, &profile); err == nil {
return &profile, nil return &profile, nil
} }
@@ -111,9 +106,11 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
return nil, fmt.Errorf("查询档案失败: %w", err) return nil, fmt.Errorf("查询档案失败: %w", err)
} }
// 存入缓存(异步) // 存入缓存(异步5分钟过期
if profile2 != nil { if profile2 != nil {
s.cache.SetAsync(context.Background(), cacheKey, profile2, s.cache.Policy.ProfileTTL) go func() {
_ = s.cache.Set(context.Background(), cacheKey, profile2, 5*time.Minute)
}()
} }
return profile2, nil return profile2, nil
@@ -123,7 +120,7 @@ func (s *profileService) GetByUserID(ctx context.Context, userID int64) ([]*mode
// 尝试从缓存获取 // 尝试从缓存获取
cacheKey := s.cacheKeys.ProfileList(userID) cacheKey := s.cacheKeys.ProfileList(userID)
var profiles []*model.Profile var profiles []*model.Profile
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profiles); ok { if err := s.cache.Get(ctx, cacheKey, &profiles); err == nil {
return profiles, nil return profiles, nil
} }
@@ -133,9 +130,11 @@ func (s *profileService) GetByUserID(ctx context.Context, userID int64) ([]*mode
return nil, fmt.Errorf("查询档案列表失败: %w", err) return nil, fmt.Errorf("查询档案列表失败: %w", err)
} }
// 存入缓存(异步) // 存入缓存(异步3分钟过期
if profiles != nil { if profiles != nil {
s.cache.SetAsync(context.Background(), cacheKey, profiles, s.cache.Policy.ProfileListTTL) go func() {
_ = s.cache.Set(context.Background(), cacheKey, profiles, 3*time.Minute)
}()
} }
return profiles, nil return profiles, nil
@@ -215,34 +214,6 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
return nil return nil
} }
func (s *profileService) SetActive(ctx context.Context, uuid string, userID int64) error {
// 获取档案并验证权限
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProfileNotFound
}
return fmt.Errorf("查询档案失败: %w", err)
}
if profile.UserID != userID {
return ErrProfileNoPermission
}
if err := s.profileRepo.SetActive(ctx, uuid, userID); err != nil {
return fmt.Errorf("设置活跃状态失败: %w", err)
}
if err := s.profileRepo.UpdateLastUsedAt(ctx, uuid); err != nil {
return fmt.Errorf("更新使用时间失败: %w", err)
}
// 清除该用户所有 profile 的缓存(因为活跃状态改变了)
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.ProfilePattern(userID))
return nil
}
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error { func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
count, err := s.profileRepo.CountByUserID(ctx, userID) count, err := s.profileRepo.CountByUserID(ctx, userID)
if err != nil { if err != nil {

View File

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

View File

@@ -0,0 +1,121 @@
package skin_renderer
import (
"bytes"
"image"
"image/png"
)
// CapeRenderer 披风渲染器
type CapeRenderer struct{}
// NewCapeRenderer 创建披风渲染器
func NewCapeRenderer() *CapeRenderer {
return &CapeRenderer{}
}
// Render 渲染披风
// 披风纹理布局:
// - 正面: (1, 1) 到 (11, 17) - 10x16 像素
// - 背面: (12, 1) 到 (22, 17) - 10x16 像素
func (r *CapeRenderer) Render(capeData []byte, height int) (image.Image, error) {
// 解码披风图像
img, err := png.Decode(bytes.NewReader(capeData))
if err != nil {
return nil, err
}
bounds := img.Bounds()
srcWidth := bounds.Dx()
srcHeight := bounds.Dy()
// 披风纹理可能是 64x32 或 22x17
// 标准披风正面区域
var frontX, frontY, frontW, frontH int
if srcWidth >= 64 && srcHeight >= 32 {
// 64x32 格式Minecraft 1.8+
// 正面: (1, 1) 到 (11, 17)
frontX = 1
frontY = 1
frontW = 10
frontH = 16
} else if srcWidth >= 22 && srcHeight >= 17 {
// 22x17 格式(旧版)
frontX = 1
frontY = 1
frontW = 10
frontH = 16
} else {
// 未知格式,直接缩放整个图像
return resizeImageBilinear(img, height*srcWidth/srcHeight, height), nil
}
// 提取正面区域
front := image.NewRGBA(image.Rect(0, 0, frontW, frontH))
for y := 0; y < frontH; y++ {
for x := 0; x < frontW; x++ {
front.Set(x, y, img.At(bounds.Min.X+frontX+x, bounds.Min.Y+frontY+y))
}
}
// 计算输出尺寸,保持宽高比
outputWidth := height * frontW / frontH
if outputWidth < 1 {
outputWidth = 1
}
// 使用最近邻缩放保持像素风格
return scaleNearest(front, outputWidth, height), nil
}
// scaleNearest 最近邻缩放
func scaleNearest(src image.Image, width, height int) *image.RGBA {
bounds := src.Bounds()
srcW := bounds.Dx()
srcH := bounds.Dy()
dst := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
srcX := bounds.Min.X + x*srcW/width
srcY := bounds.Min.Y + y*srcH/height
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
// resizeImageBilinear 双线性插值缩放
func resizeImageBilinear(src image.Image, width, height int) *image.RGBA {
bounds := src.Bounds()
srcW := float64(bounds.Dx())
srcH := float64(bounds.Dy())
dst := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// 计算源图像中的位置
srcX := float64(x) * srcW / float64(width)
srcY := float64(y) * srcH / float64(height)
// 简单的最近邻(可以改进为双线性)
ix := int(srcX)
iy := int(srcY)
if ix >= bounds.Dx() {
ix = bounds.Dx() - 1
}
if iy >= bounds.Dy() {
iy = bounds.Dy() - 1
}
dst.Set(x, y, src.At(bounds.Min.X+ix, bounds.Min.Y+iy))
}
}
return dst
}

View File

@@ -0,0 +1,113 @@
package skin_renderer
import (
"image"
"image/color"
"image/draw"
)
// Minecraft 提供 Minecraft 皮肤渲染的入口方法
// 与 blessing/texture-renderer 的 Minecraft 类保持兼容
type Minecraft struct{}
// NewMinecraft 创建 Minecraft 渲染器实例
func NewMinecraft() *Minecraft {
return &Minecraft{}
}
// RenderSkin 渲染完整皮肤预览(正面+背面)
// ratio: 缩放比例,默认 7.0
// isAlex: 是否为 Alex 模型(细手臂)
func (m *Minecraft) RenderSkin(skinData []byte, ratio float64, isAlex bool) (image.Image, error) {
vp := 15 // vertical padding
hp := 30 // horizontal padding
ip := 15 // internal padding
// 渲染正面(-45度
frontRenderer := NewSkinRenderer(ratio, false, -45, -25)
front, err := frontRenderer.Render(skinData, isAlex)
if err != nil {
return nil, err
}
// 渲染背面135度
backRenderer := NewSkinRenderer(ratio, false, 135, -25)
back, err := backRenderer.Render(skinData, isAlex)
if err != nil {
return nil, err
}
width := front.Bounds().Dx()
height := front.Bounds().Dy()
// 创建画布
canvas := createEmptyCanvas((hp+width+ip)*2, vp*2+height)
// 绘制背面(左侧)
draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+height), back, back.Bounds().Min, draw.Over)
// 绘制正面(右侧)
draw.Draw(canvas, image.Rect(hp+width+ip*2, vp, hp+width*2+ip*2, vp+height), front, front.Bounds().Min, draw.Over)
return canvas, nil
}
// RenderCape 渲染披风
// height: 输出高度
func (m *Minecraft) RenderCape(capeData []byte, height int) (image.Image, error) {
vp := 20 // vertical padding
hp := 40 // horizontal padding
renderer := NewCapeRenderer()
cape, err := renderer.Render(capeData, height)
if err != nil {
return nil, err
}
width := cape.Bounds().Dx()
capeHeight := cape.Bounds().Dy()
canvas := createEmptyCanvas(hp*2+width, vp*2+capeHeight)
draw.Draw(canvas, image.Rect(hp, vp, hp+width, vp+capeHeight), cape, cape.Bounds().Min, draw.Over)
return canvas, nil
}
// Render2DAvatar 渲染 2D 头像
// ratio: 缩放比例,默认 15.0
func (m *Minecraft) Render2DAvatar(skinData []byte, ratio float64) (image.Image, error) {
renderer := NewSkinRendererFull(ratio, true, 0, 0, 0, 0, 0, 0, 0, true)
return renderer.Render(skinData, false)
}
// Render3DAvatar 渲染 3D 头像
// ratio: 缩放比例,默认 15.0
func (m *Minecraft) Render3DAvatar(skinData []byte, ratio float64) (image.Image, error) {
renderer := NewSkinRenderer(ratio, true, 45, -25)
return renderer.Render(skinData, false)
}
// RenderSkinWithAngle 渲染指定角度的皮肤
func (m *Minecraft) RenderSkinWithAngle(skinData []byte, ratio float64, isAlex bool, hRotation, vRotation float64) (image.Image, error) {
renderer := NewSkinRenderer(ratio, false, hRotation, vRotation)
return renderer.Render(skinData, isAlex)
}
// RenderHeadWithAngle 渲染指定角度的头像
func (m *Minecraft) RenderHeadWithAngle(skinData []byte, ratio float64, hRotation, vRotation float64) (image.Image, error) {
renderer := NewSkinRenderer(ratio, true, hRotation, vRotation)
return renderer.Render(skinData, false)
}
// createEmptyCanvas 创建透明画布
func createEmptyCanvas(width, height int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, width, height))
// 填充透明背景
transparent := color.RGBA{0, 0, 0, 0}
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.SetRGBA(x, y, transparent)
}
}
return img
}

View File

@@ -0,0 +1,95 @@
// Package skin_renderer 实现 Minecraft 皮肤的 3D 渲染
// 移植自 blessing/texture-renderer
package skin_renderer
// Point 表示 3D 空间中的一个点
type Point struct {
// 原始坐标
originX, originY, originZ float64
// 投影后的坐标
destX, destY, destZ float64
// 是否已投影
isProjected bool
isPreProjected bool
}
// NewPoint 创建一个新的 3D 点
func NewPoint(x, y, z float64) *Point {
return &Point{
originX: x,
originY: y,
originZ: z,
}
}
// Project 将 3D 点投影到 2D 平面
// 使用欧拉角旋转alpha 为垂直旋转X轴omega 为水平旋转Y轴
func (p *Point) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) {
x := p.originX
y := p.originY
z := p.originZ
// 3D 旋转投影公式
p.destX = x*cosOmega + z*sinOmega
p.destY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega
p.destZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega
p.isProjected = true
// 更新边界
if p.destX < *minX {
*minX = p.destX
}
if p.destX > *maxX {
*maxX = p.destX
}
if p.destY < *minY {
*minY = p.destY
}
if p.destY > *maxY {
*maxY = p.destY
}
}
// PreProject 预投影,用于部件独立旋转(如头部、手臂)
// dx, dy, dz 为旋转中心点
func (p *Point) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) {
if p.isPreProjected {
return
}
// 相对于旋转中心的坐标
x := p.originX - dx
y := p.originY - dy
z := p.originZ - dz
// 旋转后加回偏移
p.originX = x*cosOmega + z*sinOmega + dx
p.originY = x*sinAlpha*sinOmega + y*cosAlpha - z*sinAlpha*cosOmega + dy
p.originZ = -x*cosAlpha*sinOmega + y*sinAlpha + z*cosAlpha*cosOmega + dz
p.isPreProjected = true
}
// GetDestCoord 获取投影后的坐标
func (p *Point) GetDestCoord() (x, y, z float64) {
return p.destX, p.destY, p.destZ
}
// GetOriginCoord 获取原始坐标
func (p *Point) GetOriginCoord() (x, y, z float64) {
return p.originX, p.originY, p.originZ
}
// IsProjected 返回是否已投影
func (p *Point) IsProjected() bool {
return p.isProjected
}
// GetDepth 获取深度值(用于排序)
func (p *Point) GetDepth(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) float64 {
if !p.isProjected {
p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY)
}
return p.destZ
}

View File

@@ -0,0 +1,200 @@
package skin_renderer
import (
"image"
"image/color"
)
// Polygon 表示一个四边形面片
type Polygon struct {
dots [4]*Point
color color.RGBA
isProjected bool
face string // 面的方向: "x", "y", "z"
faceDepth float64 // 面的深度
}
// NewPolygon 创建一个新的多边形
func NewPolygon(dots [4]*Point, c color.RGBA) *Polygon {
p := &Polygon{
dots: dots,
color: c,
}
// 确定面的方向
x0, y0, z0 := dots[0].GetOriginCoord()
x1, y1, z1 := dots[1].GetOriginCoord()
x2, y2, z2 := dots[2].GetOriginCoord()
if x0 == x1 && x1 == x2 {
p.face = "x"
p.faceDepth = x0
} else if y0 == y1 && y1 == y2 {
p.face = "y"
p.faceDepth = y0
} else if z0 == z1 && z1 == z2 {
p.face = "z"
p.faceDepth = z0
}
return p
}
// Project 投影多边形的所有顶点
func (p *Polygon) Project(cosAlpha, sinAlpha, cosOmega, sinOmega float64, minX, maxX, minY, maxY *float64) {
for _, dot := range p.dots {
if !dot.IsProjected() {
dot.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, minX, maxX, minY, maxY)
}
}
p.isProjected = true
}
// PreProject 预投影多边形的所有顶点
func (p *Polygon) PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega float64) {
for _, dot := range p.dots {
dot.PreProject(dx, dy, dz, cosAlpha, sinAlpha, cosOmega, sinOmega)
}
}
// IsProjected 返回是否已投影
func (p *Polygon) IsProjected() bool {
return p.isProjected
}
// AddToImage 将多边形绘制到图像上
func (p *Polygon) AddToImage(img *image.RGBA, minX, minY, ratio float64) {
// 检查透明度,完全透明则跳过
if p.color.A == 0 {
return
}
// 获取投影后的 2D 坐标
points := make([][2]float64, 4)
var coordX, coordY *float64
samePlanX := true
samePlanY := true
for i, dot := range p.dots {
x, y, _ := dot.GetDestCoord()
points[i] = [2]float64{
(x - minX) * ratio,
(y - minY) * ratio,
}
if coordX == nil {
coordX = &x
coordY = &y
} else {
if *coordX != x {
samePlanX = false
}
if *coordY != y {
samePlanY = false
}
}
}
// 如果所有点在同一平面(退化面),跳过
if samePlanX || samePlanY {
return
}
// 使用扫描线算法填充多边形
fillPolygon(img, points, p.color)
}
// fillPolygon 使用扫描线算法填充四边形
func fillPolygon(img *image.RGBA, points [][2]float64, c color.RGBA) {
// 找到 Y 的范围
minY := points[0][1]
maxY := points[0][1]
for _, pt := range points {
if pt[1] < minY {
minY = pt[1]
}
if pt[1] > maxY {
maxY = pt[1]
}
}
bounds := img.Bounds()
// 扫描每一行
for y := int(minY); y <= int(maxY); y++ {
if y < bounds.Min.Y || y >= bounds.Max.Y {
continue
}
// 找到这一行与多边形边的交点
var intersections []float64
n := len(points)
for i := 0; i < n; i++ {
j := (i + 1) % n
y1, y2 := points[i][1], points[j][1]
x1, x2 := points[i][0], points[j][0]
// 检查这条边是否与当前扫描线相交
if (y1 <= float64(y) && y2 > float64(y)) || (y2 <= float64(y) && y1 > float64(y)) {
// 计算交点的 X 坐标
t := (float64(y) - y1) / (y2 - y1)
x := x1 + t*(x2-x1)
intersections = append(intersections, x)
}
}
// 排序交点
for i := 0; i < len(intersections)-1; i++ {
for j := i + 1; j < len(intersections); j++ {
if intersections[i] > intersections[j] {
intersections[i], intersections[j] = intersections[j], intersections[i]
}
}
}
// 填充交点之间的像素
for i := 0; i+1 < len(intersections); i += 2 {
xStart := int(intersections[i])
xEnd := int(intersections[i+1])
for x := xStart; x <= xEnd; x++ {
if x >= bounds.Min.X && x < bounds.Max.X {
// Alpha 混合
if c.A == 255 {
img.SetRGBA(x, y, c)
} else {
existing := img.RGBAAt(x, y)
blended := alphaBlend(existing, c)
img.SetRGBA(x, y, blended)
}
}
}
}
}
}
// alphaBlend 执行 Alpha 混合
func alphaBlend(dst, src color.RGBA) color.RGBA {
if src.A == 0 {
return dst
}
if src.A == 255 {
return src
}
srcA := float64(src.A) / 255.0
dstA := float64(dst.A) / 255.0
outA := srcA + dstA*(1-srcA)
if outA == 0 {
return color.RGBA{}
}
return color.RGBA{
R: uint8((float64(src.R)*srcA + float64(dst.R)*dstA*(1-srcA)) / outA),
G: uint8((float64(src.G)*srcA + float64(dst.G)*dstA*(1-srcA)) / outA),
B: uint8((float64(src.B)*srcA + float64(dst.B)*dstA*(1-srcA)) / outA),
A: uint8(outA * 255),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,591 @@
package skin_renderer
import (
"bytes"
"image"
"image/color"
"image/png"
"math"
)
// SkinRenderer 皮肤渲染器
type SkinRenderer struct {
playerSkin image.Image
isNewSkinType bool
isAlex bool
hdRatio int
// 旋转参数
ratio float64
headOnly bool
hR float64 // 水平旋转角度
vR float64 // 垂直旋转角度
hrh float64 // 头部水平旋转
vrll float64 // 左腿垂直旋转
vrrl float64 // 右腿垂直旋转
vrla float64 // 左臂垂直旋转
vrra float64 // 右臂垂直旋转
layers bool // 是否渲染第二层
// 计算后的三角函数值
cosAlpha, sinAlpha float64
cosOmega, sinOmega float64
// 边界
minX, maxX, minY, maxY float64
// 各部件的旋转角度
membersAngles map[string]angleSet
// 可见面
visibleFaces map[string]faceVisibility
frontFaces []string
backFaces []string
// 多边形
polygons map[string]map[string][]*Polygon
}
type angleSet struct {
cosAlpha, sinAlpha float64
cosOmega, sinOmega float64
}
type faceVisibility struct {
front []string
back []string
}
var allFaces = []string{"back", "right", "top", "front", "left", "bottom"}
// NewSkinRenderer 创建皮肤渲染器
func NewSkinRenderer(ratio float64, headOnly bool, horizontalRotation, verticalRotation float64) *SkinRenderer {
return &SkinRenderer{
ratio: ratio,
headOnly: headOnly,
hR: horizontalRotation,
vR: verticalRotation,
hrh: 0,
vrll: 0,
vrrl: 0,
vrla: 0,
vrra: 0,
layers: true,
}
}
// NewSkinRendererFull 创建带完整参数的皮肤渲染器
func NewSkinRendererFull(ratio float64, headOnly bool, hR, vR, hrh, vrll, vrrl, vrla, vrra float64, layers bool) *SkinRenderer {
return &SkinRenderer{
ratio: ratio,
headOnly: headOnly,
hR: hR,
vR: vR,
hrh: hrh,
vrll: vrll,
vrrl: vrrl,
vrla: vrla,
vrra: vrra,
layers: layers,
}
}
// Render 渲染皮肤
func (r *SkinRenderer) Render(skinData []byte, isAlex bool) (image.Image, error) {
// 解码皮肤图像
img, err := png.Decode(bytes.NewReader(skinData))
if err != nil {
return nil, err
}
r.playerSkin = img
r.isAlex = isAlex
// 计算 HD 比例
sourceWidth := img.Bounds().Dx()
sourceHeight := img.Bounds().Dy()
// 防止内存溢出,限制最大尺寸
if sourceWidth > 256 {
r.playerSkin = resizeImage(img, 256, sourceHeight*256/sourceWidth)
}
r.hdRatio = r.playerSkin.Bounds().Dx() / 64
// 检查是否为新版皮肤格式64x64
if r.playerSkin.Bounds().Dx() == r.playerSkin.Bounds().Dy() {
r.isNewSkinType = true
}
// 转换为 RGBA
r.playerSkin = convertToRGBA(r.playerSkin)
// 处理背景透明
r.makeBackgroundTransparent()
// 计算角度
r.calculateAngles()
// 确定可见面
r.facesDetermination()
// 生成多边形
r.generatePolygons()
// 部件旋转
r.memberRotation()
// 创建投影
r.createProjectionPlan()
// 渲染图像
return r.displayImage(), nil
}
// makeBackgroundTransparent 处理背景透明
func (r *SkinRenderer) makeBackgroundTransparent() {
rgba, ok := r.playerSkin.(*image.RGBA)
if !ok {
return
}
// 检查左上角 8x8 区域是否为纯色
var tempColor color.RGBA
needRemove := true
first := true
for y := 0; y < 8; y++ {
for x := 0; x < 8; x++ {
c := rgba.RGBAAt(x, y)
// 如果已有透明度,不需要处理
if c.A < 128 {
needRemove = false
break
}
if first {
tempColor = c
first = false
} else if c != tempColor {
needRemove = false
break
}
}
if !needRemove {
break
}
}
if !needRemove {
return
}
// 将该颜色设为透明
bounds := rgba.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
c := rgba.RGBAAt(x, y)
if c.R == tempColor.R && c.G == tempColor.G && c.B == tempColor.B {
rgba.SetRGBA(x, y, color.RGBA{0, 0, 0, 0})
}
}
}
}
// calculateAngles 计算旋转角度
func (r *SkinRenderer) calculateAngles() {
// 转换为弧度
alpha := r.vR * math.Pi / 180
omega := r.hR * math.Pi / 180
r.cosAlpha = math.Cos(alpha)
r.sinAlpha = math.Sin(alpha)
r.cosOmega = math.Cos(omega)
r.sinOmega = math.Sin(omega)
r.membersAngles = make(map[string]angleSet)
// 躯干不旋转
r.membersAngles["torso"] = angleSet{
cosAlpha: 1, sinAlpha: 0,
cosOmega: 1, sinOmega: 0,
}
// 头部旋转
omegaHead := r.hrh * math.Pi / 180
r.membersAngles["head"] = angleSet{
cosAlpha: 1, sinAlpha: 0,
cosOmega: math.Cos(omegaHead), sinOmega: math.Sin(omegaHead),
}
r.membersAngles["helmet"] = r.membersAngles["head"]
// 右臂旋转
alphaRightArm := r.vrra * math.Pi / 180
r.membersAngles["rightArm"] = angleSet{
cosAlpha: math.Cos(alphaRightArm), sinAlpha: math.Sin(alphaRightArm),
cosOmega: 1, sinOmega: 0,
}
// 左臂旋转
alphaLeftArm := r.vrla * math.Pi / 180
r.membersAngles["leftArm"] = angleSet{
cosAlpha: math.Cos(alphaLeftArm), sinAlpha: math.Sin(alphaLeftArm),
cosOmega: 1, sinOmega: 0,
}
// 右腿旋转
alphaRightLeg := r.vrrl * math.Pi / 180
r.membersAngles["rightLeg"] = angleSet{
cosAlpha: math.Cos(alphaRightLeg), sinAlpha: math.Sin(alphaRightLeg),
cosOmega: 1, sinOmega: 0,
}
// 左腿旋转
alphaLeftLeg := r.vrll * math.Pi / 180
r.membersAngles["leftLeg"] = angleSet{
cosAlpha: math.Cos(alphaLeftLeg), sinAlpha: math.Sin(alphaLeftLeg),
cosOmega: 1, sinOmega: 0,
}
r.minX, r.maxX = 0, 0
r.minY, r.maxY = 0, 0
}
// facesDetermination 确定可见面
func (r *SkinRenderer) facesDetermination() {
r.visibleFaces = make(map[string]faceVisibility)
parts := []string{"head", "torso", "rightArm", "leftArm", "rightLeg", "leftLeg"}
for _, part := range parts {
angles := r.membersAngles[part]
// 创建测试立方体点
cubePoints := r.createCubePoints()
var maxDepthPoint *Point
var maxDepthFaces []string
for _, cp := range cubePoints {
point := cp.point
point.PreProject(0, 0, 0, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
if maxDepthPoint == nil {
maxDepthPoint = point
maxDepthFaces = cp.faces
} else {
_, _, z1 := maxDepthPoint.GetDestCoord()
_, _, z2 := point.GetDestCoord()
if z1 > z2 {
maxDepthPoint = point
maxDepthFaces = cp.faces
}
}
}
r.visibleFaces[part] = faceVisibility{
back: maxDepthFaces,
front: diffFaces(allFaces, maxDepthFaces),
}
}
// 确定全局前后面
cubePoints := r.createCubePoints()
var maxDepthPoint *Point
var maxDepthFaces []string
for _, cp := range cubePoints {
point := cp.point
point.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
if maxDepthPoint == nil {
maxDepthPoint = point
maxDepthFaces = cp.faces
} else {
_, _, z1 := maxDepthPoint.GetDestCoord()
_, _, z2 := point.GetDestCoord()
if z1 > z2 {
maxDepthPoint = point
maxDepthFaces = cp.faces
}
}
}
r.backFaces = maxDepthFaces
r.frontFaces = diffFaces(allFaces, maxDepthFaces)
}
type cubePoint struct {
point *Point
faces []string
}
func (r *SkinRenderer) createCubePoints() []cubePoint {
return []cubePoint{
{NewPoint(0, 0, 0), []string{"back", "right", "top"}},
{NewPoint(0, 0, 1), []string{"front", "right", "top"}},
{NewPoint(0, 1, 0), []string{"back", "right", "bottom"}},
{NewPoint(0, 1, 1), []string{"front", "right", "bottom"}},
{NewPoint(1, 0, 0), []string{"back", "left", "top"}},
{NewPoint(1, 0, 1), []string{"front", "left", "top"}},
{NewPoint(1, 1, 0), []string{"back", "left", "bottom"}},
{NewPoint(1, 1, 1), []string{"front", "left", "bottom"}},
}
}
func diffFaces(all, exclude []string) []string {
excludeMap := make(map[string]bool)
for _, f := range exclude {
excludeMap[f] = true
}
var result []string
for _, f := range all {
if !excludeMap[f] {
result = append(result, f)
}
}
return result
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// memberRotation 部件旋转
func (r *SkinRenderer) memberRotation() {
hd := float64(r.hdRatio)
// 头部和头盔旋转
angles := r.membersAngles["head"]
for _, face := range r.polygons["head"] {
for _, poly := range face {
poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
}
}
for _, face := range r.polygons["helmet"] {
for _, poly := range face {
poly.PreProject(4*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
}
}
if r.headOnly {
return
}
// 右臂旋转
angles = r.membersAngles["rightArm"]
for _, face := range r.polygons["rightArm"] {
for _, poly := range face {
poly.PreProject(-2*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
}
}
// 左臂旋转
angles = r.membersAngles["leftArm"]
for _, face := range r.polygons["leftArm"] {
for _, poly := range face {
poly.PreProject(10*hd, 8*hd, 2*hd, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
}
}
// 右腿旋转
angles = r.membersAngles["rightLeg"]
zOffset := 4 * hd
if angles.sinAlpha < 0 {
zOffset = 0
}
for _, face := range r.polygons["rightLeg"] {
for _, poly := range face {
poly.PreProject(2*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
}
}
// 左腿旋转
angles = r.membersAngles["leftLeg"]
zOffset = 4 * hd
if angles.sinAlpha < 0 {
zOffset = 0
}
for _, face := range r.polygons["leftLeg"] {
for _, poly := range face {
poly.PreProject(6*hd, 20*hd, zOffset, angles.cosAlpha, angles.sinAlpha, angles.cosOmega, angles.sinOmega)
}
}
}
// createProjectionPlan 创建投影
func (r *SkinRenderer) createProjectionPlan() {
for _, piece := range r.polygons {
for _, face := range piece {
for _, poly := range face {
if !poly.IsProjected() {
poly.Project(r.cosAlpha, r.sinAlpha, r.cosOmega, r.sinOmega, &r.minX, &r.maxX, &r.minY, &r.maxY)
}
}
}
}
}
// displayImage 渲染最终图像
func (r *SkinRenderer) displayImage() image.Image {
width := r.maxX - r.minX
height := r.maxY - r.minY
ratio := r.ratio * 2
srcWidth := int(ratio*width) + 1
srcHeight := int(ratio*height) + 1
img := image.NewRGBA(image.Rect(0, 0, srcWidth, srcHeight))
// 按深度顺序绘制
displayOrder := r.getDisplayOrder()
for _, order := range displayOrder {
for piece, faces := range order {
for _, face := range faces {
if polys, ok := r.polygons[piece][face]; ok {
for _, poly := range polys {
poly.AddToImage(img, r.minX, r.minY, ratio)
}
}
}
}
}
// 抗锯齿2x 渲染后缩小
realWidth := srcWidth / 2
realHeight := srcHeight / 2
destImg := resizeImage(img, realWidth, realHeight)
return destImg
}
// getDisplayOrder 获取绘制顺序
func (r *SkinRenderer) getDisplayOrder() []map[string][]string {
var displayOrder []map[string][]string
if contains(r.frontFaces, "top") {
if contains(r.frontFaces, "right") {
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
} else {
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
}
displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front})
displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front})
} else {
displayOrder = append(displayOrder, map[string][]string{"helmet": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"head": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"head": r.visibleFaces["head"].front})
displayOrder = append(displayOrder, map[string][]string{"helmet": r.visibleFaces["head"].front})
if contains(r.frontFaces, "right") {
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
} else {
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightArm": r.visibleFaces["rightArm"].front})
displayOrder = append(displayOrder, map[string][]string{"torso": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"torso": r.visibleFaces["torso"].front})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftArm": r.visibleFaces["leftArm"].front})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"rightLeg": r.visibleFaces["rightLeg"].front})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.backFaces})
displayOrder = append(displayOrder, map[string][]string{"leftLeg": r.visibleFaces["leftLeg"].front})
}
}
return displayOrder
}
// 辅助函数
func convertToRGBA(img image.Image) *image.RGBA {
if rgba, ok := img.(*image.RGBA); ok {
return rgba
}
bounds := img.Bounds()
rgba := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
rgba.Set(x, y, img.At(x, y))
}
}
return rgba
}
func resizeImage(img image.Image, width, height int) *image.RGBA {
bounds := img.Bounds()
srcW := bounds.Dx()
srcH := bounds.Dy()
dst := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
srcX := bounds.Min.X + x*srcW/width
srcY := bounds.Min.Y + y*srcH/height
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}
// getPixelColor 从皮肤图像获取像素颜色
func (r *SkinRenderer) getPixelColor(x, y int) color.RGBA {
if x < 0 || y < 0 {
return color.RGBA{}
}
bounds := r.playerSkin.Bounds()
if x >= bounds.Dx() || y >= bounds.Dy() {
return color.RGBA{}
}
c := r.playerSkin.At(bounds.Min.X+x, bounds.Min.Y+y)
r32, g32, b32, a32 := c.RGBA()
return color.RGBA{
R: uint8(r32 >> 8),
G: uint8(g32 >> 8),
B: uint8(b32 >> 8),
A: uint8(a32 >> 8),
}
}

View File

@@ -0,0 +1,203 @@
package skin_renderer
import (
"image"
"image/color"
"image/png"
"os"
"testing"
)
// createTestSkin 创建一个测试用的 64x64 皮肤图像
func createTestSkin() []byte {
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
// 填充一些测试颜色
// 头部区域 (8,8) - (16,16)
for y := 8; y < 16; y++ {
for x := 8; x < 16; x++ {
img.Set(x, y, image.White)
}
}
// 躯干区域 (20,20) - (28,32)
for y := 20; y < 32; y++ {
for x := 20; x < 28; x++ {
img.Set(x, y, image.Black)
}
}
// 编码为 PNG
f, _ := os.CreateTemp("", "test_skin_*.png")
defer os.Remove(f.Name())
defer f.Close()
png.Encode(f, img)
f.Seek(0, 0)
data, _ := os.ReadFile(f.Name())
return data
}
func TestSkinRenderer_Render(t *testing.T) {
skinData := createTestSkin()
if len(skinData) == 0 {
t.Skip("无法创建测试皮肤")
}
renderer := NewSkinRenderer(7.0, false, -45, -25)
result, err := renderer.Render(skinData, false)
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if result == nil {
t.Fatal("渲染结果为空")
}
bounds := result.Bounds()
if bounds.Dx() == 0 || bounds.Dy() == 0 {
t.Error("渲染结果尺寸为零")
}
t.Logf("渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
}
func TestSkinRenderer_RenderHeadOnly(t *testing.T) {
skinData := createTestSkin()
if len(skinData) == 0 {
t.Skip("无法创建测试皮肤")
}
renderer := NewSkinRenderer(15.0, true, 45, -25)
result, err := renderer.Render(skinData, false)
if err != nil {
t.Fatalf("渲染头像失败: %v", err)
}
if result == nil {
t.Fatal("渲染结果为空")
}
bounds := result.Bounds()
t.Logf("头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
}
func TestMinecraft_RenderSkin(t *testing.T) {
skinData := createTestSkin()
if len(skinData) == 0 {
t.Skip("无法创建测试皮肤")
}
mc := NewMinecraft()
result, err := mc.RenderSkin(skinData, 7.0, false)
if err != nil {
t.Fatalf("RenderSkin 失败: %v", err)
}
if result == nil {
t.Fatal("渲染结果为空")
}
bounds := result.Bounds()
t.Logf("完整皮肤渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
}
func TestMinecraft_Render2DAvatar(t *testing.T) {
skinData := createTestSkin()
if len(skinData) == 0 {
t.Skip("无法创建测试皮肤")
}
mc := NewMinecraft()
result, err := mc.Render2DAvatar(skinData, 15.0)
if err != nil {
t.Fatalf("Render2DAvatar 失败: %v", err)
}
if result == nil {
t.Fatal("渲染结果为空")
}
bounds := result.Bounds()
t.Logf("2D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
}
func TestMinecraft_Render3DAvatar(t *testing.T) {
skinData := createTestSkin()
if len(skinData) == 0 {
t.Skip("无法创建测试皮肤")
}
mc := NewMinecraft()
result, err := mc.Render3DAvatar(skinData, 15.0)
if err != nil {
t.Fatalf("Render3DAvatar 失败: %v", err)
}
if result == nil {
t.Fatal("渲染结果为空")
}
bounds := result.Bounds()
t.Logf("3D头像渲染结果尺寸: %dx%d", bounds.Dx(), bounds.Dy())
}
func TestPoint_Project(t *testing.T) {
p := NewPoint(1, 2, 3)
var minX, maxX, minY, maxY float64
// 测试 45 度旋转
cosAlpha := 0.9063077870366499 // cos(-25°)
sinAlpha := -0.42261826174069944 // sin(-25°)
cosOmega := 0.7071067811865476 // cos(45°)
sinOmega := 0.7071067811865476 // sin(45°)
p.Project(cosAlpha, sinAlpha, cosOmega, sinOmega, &minX, &maxX, &minY, &maxY)
x, y, z := p.GetDestCoord()
t.Logf("投影结果: x=%.2f, y=%.2f, z=%.2f", x, y, z)
if !p.IsProjected() {
t.Error("点应该标记为已投影")
}
}
func TestPolygon_AddToImage(t *testing.T) {
// 创建一个简单的正方形多边形
p1 := NewPoint(0, 0, 0)
p2 := NewPoint(10, 0, 0)
p3 := NewPoint(10, 10, 0)
p4 := NewPoint(0, 10, 0)
var minX, maxX, minY, maxY float64
p1.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
p2.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
p3.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
p4.Project(1, 0, 1, 0, &minX, &maxX, &minY, &maxY)
poly := NewPolygon([4]*Point{p1, p2, p3, p4}, color.RGBA{R: 255, G: 255, B: 255, A: 255})
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
poly.AddToImage(img, minX, minY, 5.0)
// 检查是否有像素被绘制
hasPixels := false
for y := 0; y < 100; y++ {
for x := 0; x < 100; x++ {
c := img.RGBAAt(x, y)
if c.A > 0 {
hasPixels = true
break
}
}
if hasPixels {
break
}
}
if !hasPixels {
t.Error("多边形应该在图像上绘制了像素")
}
}

View File

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

View File

@@ -13,6 +13,7 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -47,62 +48,11 @@ func NewTextureService(
} }
} }
func (s *textureService) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil {
return nil, ErrUserNotFound
}
// 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
// 如果已存在相同Hash的皮肤复用已存在的URL
finalURL := url
if existingTexture != nil {
finalURL = existingTexture.URL
}
// 转换材质类型
textureTypeEnum, err := parseTextureTypeInternal(textureType)
if err != nil {
return nil, err
}
// 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: finalURL, // 复用已存在的URL或使用新URL
Hash: hash,
Size: size,
IsPublic: isPublic,
IsSlim: isSlim,
Status: 1,
DownloadCount: 0,
FavoriteCount: 0,
}
if err := s.textureRepo.Create(ctx, texture); err != nil {
return nil, err
}
// 清除用户的 texture 列表缓存(所有分页)
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
return texture, nil
}
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) { func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
// 尝试从缓存获取 // 尝试从缓存获取
cacheKey := s.cacheKeys.Texture(id) cacheKey := s.cacheKeys.Texture(id)
var texture model.Texture var texture model.Texture
if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok { if err := s.cache.Get(ctx, cacheKey, &texture); err == nil {
if texture.Status == -1 { if texture.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
@@ -121,9 +71,11 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
// 存入缓存(异步) // 存入缓存(异步5分钟过期
if texture2 != nil { if texture2 != nil {
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL) go func() {
_ = s.cache.Set(context.Background(), cacheKey, texture2, 5*time.Minute)
}()
} }
return texture2, nil return texture2, nil
@@ -133,7 +85,7 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
// 尝试从缓存获取 // 尝试从缓存获取
cacheKey := s.cacheKeys.TextureByHash(hash) cacheKey := s.cacheKeys.TextureByHash(hash)
var texture model.Texture var texture model.Texture
if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok { if err := s.cache.Get(ctx, cacheKey, &texture); err == nil {
if texture.Status == -1 { if texture.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
@@ -152,8 +104,10 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
} }
// 存入缓存(异步) // 存入缓存(异步5分钟过期
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL) go func() {
_ = s.cache.Set(context.Background(), cacheKey, texture2, 5*time.Minute)
}()
return texture2, nil return texture2, nil
} }
@@ -167,7 +121,7 @@ func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page
Textures []*model.Texture Textures []*model.Texture
Total int64 Total int64
} }
if ok, _ := s.cache.TryGet(ctx, cacheKey, &cachedResult); ok { if err := s.cache.Get(ctx, cacheKey, &cachedResult); err == nil {
return cachedResult.Textures, cachedResult.Total, nil return cachedResult.Textures, cachedResult.Total, nil
} }
@@ -177,12 +131,14 @@ func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page
return nil, 0, err return nil, 0, err
} }
// 存入缓存(异步) // 存入缓存(异步2分钟过期
result := struct { go func() {
Textures []*model.Texture result := struct {
Total int64 Textures []*model.Texture
}{Textures: textures, Total: total} Total int64
s.cache.SetAsync(context.Background(), cacheKey, result, s.cache.Policy.TextureListTTL) }{Textures: textures, Total: total}
_ = s.cache.Set(context.Background(), cacheKey, result, 2*time.Minute)
}()
return textures, total, nil return textures, total, nil
} }
@@ -225,7 +181,7 @@ func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64
// 清除 texture 缓存和用户列表缓存 // 清除 texture 缓存和用户列表缓存
s.cacheInv.OnUpdate(ctx, s.cacheKeys.Texture(textureID)) s.cacheInv.OnUpdate(ctx, s.cacheKeys.Texture(textureID))
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID)) s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
return s.textureRepo.FindByID(ctx, textureID) return s.textureRepo.FindByID(ctx, textureID)
} }
@@ -250,7 +206,7 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
// 清除 texture 缓存和用户列表缓存 // 清除 texture 缓存和用户列表缓存
s.cacheInv.OnDelete(ctx, s.cacheKeys.Texture(textureID)) s.cacheInv.OnDelete(ctx, s.cacheKeys.Texture(textureID))
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID)) s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
return nil return nil
} }

View File

@@ -478,128 +478,6 @@ func boolPtr(b bool) *bool {
// 使用 Mock 的集成测试 // 使用 Mock 的集成测试
// ============================================================================ // ============================================================================
// TestTextureServiceImpl_Create 测试创建Texture
func TestTextureServiceImpl_Create(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户
testUser := &model.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
Status: 1,
}
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, 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: false,
setupMocks: func() {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: "existing-hash",
})
},
},
{
name: "无效的材质类型",
uploaderID: 1,
textureName: "InvalidTypeTexture",
textureType: "INVALID",
hash: "unique-hash-4",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks != nil {
tt.setupMocks()
}
ctx := context.Background()
texture, err := textureService.Create(
ctx,
tt.uploaderID,
tt.textureName,
"Test description",
tt.textureType,
"http://example.com/texture.png",
tt.hash,
512,
true,
false,
)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Error("返回的Texture不应为nil")
}
if texture.Name != tt.textureName {
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
}
}
})
}
}
// TestTextureServiceImpl_GetByID 测试获取Texture // TestTextureServiceImpl_GetByID 测试获取Texture
func TestTextureServiceImpl_GetByID(t *testing.T) { func TestTextureServiceImpl_GetByID(t *testing.T) {
textureRepo := NewMockTextureRepository() textureRepo := NewMockTextureRepository()

View File

@@ -0,0 +1,305 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
// tokenService TokenService的实现
type tokenService struct {
tokenRepo repository.TokenRepository
profileRepo repository.ProfileRepository
logger *zap.Logger
}
// NewTokenService 创建TokenService实例
func NewTokenService(
tokenRepo repository.TokenRepository,
profileRepo repository.ProfileRepository,
logger *zap.Logger,
) TokenService {
return &tokenService{
tokenRepo: tokenRepo,
profileRepo: profileRepo,
logger: logger,
}
}
const (
tokenExtendedTimeout = 10 * time.Second
tokensMaxCount = 10
)
func (s *tokenService) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
var (
selectedProfileID *model.Profile
availableProfiles []*model.Profile
)
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
// 验证用户存在
if UUID != "" {
_, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户信息失败: %w", err)
}
}
// 生成令牌
if clientToken == "" {
clientToken = uuid.New().String()
}
accessToken := uuid.New().String()
token := model.Token{
AccessToken: accessToken,
ClientToken: clientToken,
UserID: userID,
Usable: true,
IssueDate: time.Now(),
}
// 获取用户配置文件
profiles, err := s.profileRepo.FindByUserID(ctx, userID)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户配置文件失败: %w", err)
}
// 如果用户只有一个配置文件,自动选择
if len(profiles) == 1 {
selectedProfileID = profiles[0]
token.ProfileId = selectedProfileID.UUID
}
availableProfiles = profiles
// 插入令牌
err = s.tokenRepo.Create(ctx, &token)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("创建Token失败: %w", err)
}
// 清理多余的令牌(使用独立的后台上下文)
go s.checkAndCleanupExcessTokens(context.Background(), userID)
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
}
func (s *tokenService) Validate(ctx context.Context, accessToken, clientToken string) bool {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if accessToken == "" {
return false
}
token, err := s.tokenRepo.FindByAccessToken(ctx, accessToken)
if err != nil {
return false
}
if !token.Usable {
return false
}
if clientToken == "" {
return true
}
return token.ClientToken == clientToken
}
func (s *tokenService) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if accessToken == "" {
return "", "", errors.New("accessToken不能为空")
}
// 查找旧令牌
oldToken, err := s.tokenRepo.FindByAccessToken(ctx, accessToken)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", errors.New("accessToken无效")
}
s.logger.Error("查询Token失败", zap.Error(err), zap.String("accessToken", accessToken))
return "", "", fmt.Errorf("查询令牌失败: %w", err)
}
// 验证profile
if selectedProfileID != "" {
valid, validErr := s.validateProfileByUserID(ctx, oldToken.UserID, selectedProfileID)
if validErr != nil {
s.logger.Error("验证Profile失败",
zap.Error(err),
zap.Int64("userId", oldToken.UserID),
zap.String("profileId", selectedProfileID),
)
return "", "", fmt.Errorf("验证角色失败: %w", err)
}
if !valid {
return "", "", errors.New("角色与用户不匹配")
}
}
// 检查 clientToken 是否有效
if clientToken != "" && clientToken != oldToken.ClientToken {
return "", "", errors.New("clientToken无效")
}
// 检查 selectedProfileID 的逻辑
if selectedProfileID != "" {
if oldToken.ProfileId != "" && oldToken.ProfileId != selectedProfileID {
return "", "", errors.New("原令牌已绑定角色,无法选择新角色")
}
} else {
selectedProfileID = oldToken.ProfileId
}
// 生成新令牌
newAccessToken := uuid.New().String()
newToken := model.Token{
AccessToken: newAccessToken,
ClientToken: oldToken.ClientToken,
UserID: oldToken.UserID,
Usable: true,
ProfileId: selectedProfileID,
IssueDate: time.Now(),
}
// 先插入新令牌,再删除旧令牌
err = s.tokenRepo.Create(ctx, &newToken)
if err != nil {
s.logger.Error("创建新Token失败", zap.Error(err), zap.String("accessToken", accessToken))
return "", "", fmt.Errorf("创建新Token失败: %w", err)
}
err = s.tokenRepo.DeleteByAccessToken(ctx, accessToken)
if err != nil {
s.logger.Warn("删除旧Token失败但新Token已创建",
zap.Error(err),
zap.String("oldToken", oldToken.AccessToken),
zap.String("newToken", newAccessToken),
)
}
s.logger.Info("成功刷新Token", zap.Int64("userId", oldToken.UserID), zap.String("accessToken", newAccessToken))
return newAccessToken, oldToken.ClientToken, nil
}
func (s *tokenService) Invalidate(ctx context.Context, accessToken string) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if accessToken == "" {
return
}
err := s.tokenRepo.DeleteByAccessToken(ctx, accessToken)
if err != nil {
s.logger.Error("删除Token失败", zap.Error(err), zap.String("accessToken", accessToken))
return
}
s.logger.Info("成功删除Token", zap.String("token", accessToken))
}
func (s *tokenService) InvalidateUserTokens(ctx context.Context, userID int64) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if userID == 0 {
return
}
err := s.tokenRepo.DeleteByUserID(ctx, userID)
if err != nil {
s.logger.Error("删除用户Token失败", zap.Error(err), zap.Int64("userId", userID))
return
}
s.logger.Info("成功删除用户Token", zap.Int64("userId", userID))
}
func (s *tokenService) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
return s.tokenRepo.GetUUIDByAccessToken(ctx, accessToken)
}
func (s *tokenService) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
return s.tokenRepo.GetUserIDByAccessToken(ctx, accessToken)
}
// 私有辅助方法
func (s *tokenService) checkAndCleanupExcessTokens(ctx context.Context, userID int64) {
if userID == 0 {
return
}
// 为清理操作设置更长的超时时间
ctx, cancel := context.WithTimeout(ctx, tokenExtendedTimeout)
defer cancel()
tokens, err := s.tokenRepo.GetByUserID(ctx, userID)
if err != nil {
s.logger.Error("获取用户Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
return
}
if len(tokens) <= tokensMaxCount {
return
}
tokensToDelete := make([]string, 0, len(tokens)-tokensMaxCount)
for i := tokensMaxCount; i < len(tokens); i++ {
tokensToDelete = append(tokensToDelete, tokens[i].AccessToken)
}
deletedCount, err := s.tokenRepo.BatchDelete(ctx, tokensToDelete)
if err != nil {
s.logger.Error("清理用户多余Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
return
}
if deletedCount > 0 {
s.logger.Info("成功清理用户多余Token", zap.Int64("userId", userID), zap.Int64("count", deletedCount))
}
}
func (s *tokenService) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
if userID == 0 || UUID == "" {
return false, errors.New("用户ID或配置文件ID不能为空")
}
profile, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, errors.New("配置文件不存在")
}
return false, fmt.Errorf("验证配置文件失败: %w", err)
}
return profile.UserID == userID, nil
}

View File

@@ -7,6 +7,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -14,38 +15,40 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// tokenServiceRedis TokenService的Redis实现 // tokenServiceJWT TokenService的JWT实现使用JWT + Version机制
type tokenServiceRedis struct { type tokenServiceJWT struct {
tokenStore *auth.TokenStoreRedis tokenRepo repository.TokenRepository
clientRepo repository.ClientRepository clientRepo repository.ClientRepository
profileRepo repository.ProfileRepository profileRepo repository.ProfileRepository
yggdrasilJWT *auth.YggdrasilJWTService yggdrasilJWT *auth.YggdrasilJWTService
logger *zap.Logger logger *zap.Logger
tokenExpireSec int64 // Token过期时间0表示永不过期 tokenExpireSec int64 // Token过期时间0表示永不过期
tokenStaleSec int64 // Token过期但可用时间0表示永不过期 tokenStaleSec int64 // Token过期但可用时间0表示永不过期
} }
// NewTokenServiceRedis 创建使用Redis的TokenService实例 // NewTokenServiceJWT 创建使用JWT的TokenService实例
func NewTokenServiceRedis( func NewTokenServiceJWT(
tokenStore *auth.TokenStoreRedis, tokenRepo repository.TokenRepository,
clientRepo repository.ClientRepository, clientRepo repository.ClientRepository,
profileRepo repository.ProfileRepository, profileRepo repository.ProfileRepository,
yggdrasilJWT *auth.YggdrasilJWTService, yggdrasilJWT *auth.YggdrasilJWTService,
logger *zap.Logger, logger *zap.Logger,
) TokenService { ) TokenService {
return &tokenServiceRedis{ return &tokenServiceJWT{
tokenStore: tokenStore, tokenRepo: tokenRepo,
clientRepo: clientRepo, clientRepo: clientRepo,
profileRepo: profileRepo, profileRepo: profileRepo,
yggdrasilJWT: yggdrasilJWT, yggdrasilJWT: yggdrasilJWT,
logger: logger, logger: logger,
tokenExpireSec: 24 * 3600, // 默认24小时 tokenExpireSec: 24 * 3600, // 默认24小时
tokenStaleSec: 30 * 24 * 3600, // 默认30天 tokenStaleSec: 30 * 24 * 3600, // 默认30天
} }
} }
// Create 创建Token使用JWT + Redis存储 // 常量已在 token_service.go 中定义,这里不重复定义
func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
// Create 创建Token使用JWT + Version机制
func (s *tokenServiceJWT) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
var ( var (
selectedProfileID *model.Profile selectedProfileID *model.Profile
availableProfiles []*model.Profile availableProfiles []*model.Profile
@@ -100,7 +103,7 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
client.UpdatedAt = time.Now() client.UpdatedAt = time.Now()
if UUID != "" { if UUID != "" {
client.ProfileID = UUID client.ProfileID = UUID
if err := s.clientRepo.Update(ctx, client); err != nil { if err := s.clientRepo.Update(ctx, client); err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("更新Client失败: %w", err) return selectedProfileID, availableProfiles, "", "", fmt.Errorf("更新Client失败: %w", err)
} }
} }
@@ -131,7 +134,7 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
if s.tokenExpireSec > 0 { if s.tokenExpireSec > 0 {
expiresAt = now.Add(time.Duration(s.tokenExpireSec) * time.Second) expiresAt = now.Add(time.Duration(s.tokenExpireSec) * time.Second)
} else { } else {
// 使用遥远的未来时间 // 使用遥远的未来时间类似drasl的DISTANT_FUTURE
expiresAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC) expiresAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
} }
@@ -154,31 +157,36 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err) return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err)
} }
// 存Token到Redis // 存Token记录(用于查询和审计)
ttl := expiresAt.Sub(now) token := model.Token{
metadata := &auth.TokenMetadata{ AccessToken: accessToken,
ClientToken: clientToken,
UserID: userID, UserID: userID,
ProfileID: profileID, ProfileId: profileID,
ClientUUID: client.UUID,
ClientToken: client.ClientToken,
Version: client.Version, Version: client.Version,
CreatedAt: now.Unix(), Usable: true,
IssueDate: now,
ExpiresAt: &expiresAt,
StaleAt: &staleAt,
} }
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil { err = s.tokenRepo.Create(ctx, &token)
s.logger.Warn("存储Token到Redis失败", zap.Error(err)) if err != nil {
s.logger.Warn("保存Token记录失败但JWT已生成", zap.Error(err))
// 不返回错误因为JWT本身已经生成成功 // 不返回错误因为JWT本身已经生成成功
} }
// 清理多余的令牌(使用独立的后台上下文)
go s.checkAndCleanupExcessTokens(context.Background(), userID)
return selectedProfileID, availableProfiles, accessToken, clientToken, nil return selectedProfileID, availableProfiles, accessToken, clientToken, nil
} }
// Validate 验证Token使用JWT验证 + Redis存储验证 // Validate 验证Token使用JWT验证
func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool { func (s *tokenServiceJWT) Validate(ctx context.Context, accessToken, clientToken string) bool {
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel() defer cancel()
if accessToken == "" { if accessToken == "" {
return false return false
} }
@@ -189,13 +197,6 @@ func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientTok
return false return false
} }
// 从Redis获取Token元数据
metadata, err := s.tokenStore.Retrieve(ctx, accessToken)
if err != nil {
// Token可能已过期或不存在
return false
}
// 查找Client // 查找Client
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject) client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil { if err != nil {
@@ -208,19 +209,18 @@ func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientTok
} }
// 验证ClientToken如果提供 // 验证ClientToken如果提供
if clientToken != "" && metadata.ClientToken != clientToken { if clientToken != "" && client.ClientToken != clientToken {
return false return false
} }
return true return true
} }
// Refresh 刷新Token使用Version机制Redis存储 // Refresh 刷新Token使用Version机制无需删除旧Token
func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) { func (s *tokenServiceJWT) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) {
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel() defer cancel()
if accessToken == "" { if accessToken == "" {
return "", "", errors.New("accessToken不能为空") return "", "", errors.New("accessToken不能为空")
} }
@@ -279,11 +279,6 @@ func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToke
return "", "", fmt.Errorf("更新Client版本失败: %w", err) return "", "", fmt.Errorf("更新Client版本失败: %w", err)
} }
// 删除旧Token从Redis
if err := s.tokenStore.Delete(ctx, accessToken); err != nil {
s.logger.Warn("删除旧Token失败", zap.Error(err))
}
// 生成Token过期时间 // 生成Token过期时间
now := time.Now() now := time.Now()
var expiresAt, staleAt time.Time var expiresAt, staleAt time.Time
@@ -313,27 +308,30 @@ func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToke
return "", "", fmt.Errorf("生成新AccessToken失败: %w", err) return "", "", fmt.Errorf("生成新AccessToken失败: %w", err)
} }
// 存新Token到Redis // 存新Token记录
ttl := expiresAt.Sub(now) newToken := model.Token{
metadata := &auth.TokenMetadata{ AccessToken: newAccessToken,
UserID: client.UserID,
ProfileID: selectedProfileID,
ClientUUID: client.UUID,
ClientToken: client.ClientToken, ClientToken: client.ClientToken,
UserID: client.UserID,
ProfileId: selectedProfileID,
Version: client.Version, Version: client.Version,
CreatedAt: now.Unix(), Usable: true,
IssueDate: now,
ExpiresAt: &expiresAt,
StaleAt: &staleAt,
} }
if err := s.tokenStore.Store(ctx, newAccessToken, metadata, ttl); err != nil { err = s.tokenRepo.Create(ctx, &newToken)
s.logger.Warn("存储新Token到Redis失败", zap.Error(err)) if err != nil {
s.logger.Warn("保存新Token记录失败但JWT已生成", zap.Error(err))
} }
s.logger.Info("成功刷新Token", zap.Int64("userId", client.UserID), zap.Int("version", client.Version)) s.logger.Info("成功刷新Token", zap.Int64("userId", client.UserID), zap.Int("version", client.Version))
return newAccessToken, client.ClientToken, nil return newAccessToken, client.ClientToken, nil
} }
// Invalidate 使Token失效从Redis删除 // Invalidate 使Token失效通过增加Version
func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string) { func (s *tokenServiceJWT) Invalidate(ctx context.Context, accessToken string) {
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel() defer cancel()
@@ -349,7 +347,7 @@ func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string)
return return
} }
// 查找Client并增加Version失效所有旧Token // 查找Client并增加Version
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject) client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil { if err != nil {
s.logger.Warn("无法找到对应的Client", zap.Error(err)) s.logger.Warn("无法找到对应的Client", zap.Error(err))
@@ -364,17 +362,11 @@ func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string)
return return
} }
// 从Redis删除Token
if err := s.tokenStore.Delete(ctx, accessToken); err != nil {
s.logger.Warn("从Redis删除Token失败", zap.Error(err))
return
}
s.logger.Info("成功失效Token", zap.String("clientUUID", client.UUID), zap.Int("version", client.Version)) s.logger.Info("成功失效Token", zap.String("clientUUID", client.UUID), zap.Int("version", client.Version))
} }
// InvalidateUserTokens 使用户所有Token失效从Redis删除 // InvalidateUserTokens 使用户所有Token失效
func (s *tokenServiceRedis) InvalidateUserTokens(ctx context.Context, userID int64) { func (s *tokenServiceJWT) InvalidateUserTokens(ctx context.Context, userID int64) {
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel() defer cancel()
@@ -399,20 +391,15 @@ func (s *tokenServiceRedis) InvalidateUserTokens(ctx context.Context, userID int
} }
} }
// 从Redis删除用户所有Token
if err := s.tokenStore.DeleteByUserID(ctx, userID); err != nil {
s.logger.Error("从Redis删除用户Token失败", zap.Error(err), zap.Int64("userId", userID))
return
}
s.logger.Info("成功失效用户所有Token", zap.Int64("userId", userID), zap.Int("clientCount", len(clients))) s.logger.Info("成功失效用户所有Token", zap.Int64("userId", userID), zap.Int("clientCount", len(clients)))
} }
// GetUUIDByAccessToken 从AccessToken获取UUID通过JWT解析 // GetUUIDByAccessToken 从AccessToken获取UUID通过JWT解析
func (s *tokenServiceRedis) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) { func (s *tokenServiceJWT) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow) claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
if err != nil { if err != nil {
return "", errors.New("accessToken无效") // 如果JWT解析失败尝试从数据库查询向后兼容
return s.tokenRepo.GetUUIDByAccessToken(ctx, accessToken)
} }
if claims.ProfileID != "" { if claims.ProfileID != "" {
@@ -433,10 +420,11 @@ func (s *tokenServiceRedis) GetUUIDByAccessToken(ctx context.Context, accessToke
} }
// GetUserIDByAccessToken 从AccessToken获取UserID通过JWT解析 // GetUserIDByAccessToken 从AccessToken获取UserID通过JWT解析
func (s *tokenServiceRedis) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) { func (s *tokenServiceJWT) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow) claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
if err != nil { if err != nil {
return 0, errors.New("accessToken无效") // 如果JWT解析失败尝试从数据库查询向后兼容
return s.tokenRepo.GetUserIDByAccessToken(ctx, accessToken)
} }
// 从Client获取UserID // 从Client获取UserID
@@ -453,8 +441,44 @@ func (s *tokenServiceRedis) GetUserIDByAccessToken(ctx context.Context, accessTo
return client.UserID, nil return client.UserID, nil
} }
// validateProfileByUserID 验证Profile是否属于用户 // 私有辅助方法
func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
func (s *tokenServiceJWT) checkAndCleanupExcessTokens(ctx context.Context, userID int64) {
if userID == 0 {
return
}
// 为清理操作设置更长的超时时间
ctx, cancel := context.WithTimeout(ctx, tokenExtendedTimeout)
defer cancel()
tokens, err := s.tokenRepo.GetByUserID(ctx, userID)
if err != nil {
s.logger.Error("获取用户Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
return
}
if len(tokens) <= tokensMaxCount {
return
}
tokensToDelete := make([]string, 0, len(tokens)-tokensMaxCount)
for i := tokensMaxCount; i < len(tokens); i++ {
tokensToDelete = append(tokensToDelete, tokens[i].AccessToken)
}
deletedCount, err := s.tokenRepo.BatchDelete(ctx, tokensToDelete)
if err != nil {
s.logger.Error("清理用户多余Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10)))
return
}
if deletedCount > 0 {
s.logger.Info("成功清理用户多余Token", zap.Int64("userId", userID), zap.Int64("count", deletedCount))
}
}
func (s *tokenServiceJWT) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
if userID == 0 || UUID == "" { if userID == 0 || UUID == "" {
return false, errors.New("用户ID或配置文件ID不能为空") return false, errors.New("用户ID或配置文件ID不能为空")
} }
@@ -468,3 +492,24 @@ func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID
} }
return profile.UserID == userID, nil return profile.UserID == userID, nil
} }
// GetClientFromToken 从Token获取Client信息辅助方法
func (s *tokenServiceJWT) GetClientFromToken(ctx context.Context, accessToken string, stalePolicy auth.StaleTokenPolicy) (*model.Client, error) {
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, stalePolicy)
if err != nil {
return nil, err
}
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil {
return nil, err
}
// 验证Version
if claims.Version != client.Version {
return nil, errors.New("token版本不匹配")
}
return client, nil
}

View File

@@ -0,0 +1,512 @@
package service
import (
"carrotskin/internal/model"
"context"
"fmt"
"testing"
"go.uber.org/zap"
)
// TestTokenService_Constants 测试Token服务相关常量
func TestTokenService_Constants(t *testing.T) {
// 内部常量已私有化,通过服务行为间接测试
t.Skip("Token constants are now private - test through service behavior instead")
}
// TestTokenService_Validation 测试Token验证逻辑
func TestTokenService_Validation(t *testing.T) {
tests := []struct {
name string
accessToken string
wantValid bool
}{
{
name: "空token无效",
accessToken: "",
wantValid: false,
},
{
name: "非空token可能有效",
accessToken: "valid-token-string",
wantValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 测试空token检查逻辑
isValid := tt.accessToken != ""
if isValid != tt.wantValid {
t.Errorf("Token validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestTokenService_ClientTokenLogic 测试ClientToken逻辑
func TestTokenService_ClientTokenLogic(t *testing.T) {
tests := []struct {
name string
clientToken string
shouldGenerate bool
}{
{
name: "空的clientToken应该生成新的",
clientToken: "",
shouldGenerate: true,
},
{
name: "非空的clientToken应该使用提供的",
clientToken: "existing-client-token",
shouldGenerate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldGenerate := tt.clientToken == ""
if shouldGenerate != tt.shouldGenerate {
t.Errorf("ClientToken logic failed: got %v, want %v", shouldGenerate, tt.shouldGenerate)
}
})
}
}
// TestTokenService_ProfileSelection 测试Profile选择逻辑
func TestTokenService_ProfileSelection(t *testing.T) {
tests := []struct {
name string
profileCount int
shouldAutoSelect bool
}{
{
name: "只有一个profile时自动选择",
profileCount: 1,
shouldAutoSelect: true,
},
{
name: "多个profile时不自动选择",
profileCount: 2,
shouldAutoSelect: false,
},
{
name: "没有profile时不自动选择",
profileCount: 0,
shouldAutoSelect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldAutoSelect := tt.profileCount == 1
if shouldAutoSelect != tt.shouldAutoSelect {
t.Errorf("Profile selection logic failed: got %v, want %v", shouldAutoSelect, tt.shouldAutoSelect)
}
})
}
}
// TestTokenService_CleanupLogic 测试清理逻辑
func TestTokenService_CleanupLogic(t *testing.T) {
tests := []struct {
name string
tokenCount int
maxCount int
shouldCleanup bool
cleanupCount int
}{
{
name: "token数量未超过上限不需要清理",
tokenCount: 5,
maxCount: 10,
shouldCleanup: false,
cleanupCount: 0,
},
{
name: "token数量超过上限需要清理",
tokenCount: 15,
maxCount: 10,
shouldCleanup: true,
cleanupCount: 5,
},
{
name: "token数量等于上限不需要清理",
tokenCount: 10,
maxCount: 10,
shouldCleanup: false,
cleanupCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldCleanup := tt.tokenCount > tt.maxCount
if shouldCleanup != tt.shouldCleanup {
t.Errorf("Cleanup decision failed: got %v, want %v", shouldCleanup, tt.shouldCleanup)
}
if shouldCleanup {
expectedCleanupCount := tt.tokenCount - tt.maxCount
if expectedCleanupCount != tt.cleanupCount {
t.Errorf("Cleanup count failed: got %d, want %d", expectedCleanupCount, tt.cleanupCount)
}
}
})
}
}
// TestTokenService_UserIDValidation 测试UserID验证
func TestTokenService_UserIDValidation(t *testing.T) {
tests := []struct {
name string
userID int64
isValid bool
}{
{
name: "有效的UserID",
userID: 1,
isValid: true,
},
{
name: "UserID为0时无效",
userID: 0,
isValid: false,
},
{
name: "负数UserID无效",
userID: -1,
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.userID > 0
if isValid != tt.isValid {
t.Errorf("UserID validation failed: got %v, want %v", isValid, tt.isValid)
}
})
}
}
// ============================================================================
// 使用 Mock 的集成测试
// ============================================================================
// TestTokenServiceImpl_Create 测试创建Token
func TestTokenServiceImpl_Create(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
// 预置Profile
testProfile := &model.Profile{
UUID: "test-profile-uuid",
UserID: 1,
Name: "TestProfile",
}
_ = profileRepo.Create(context.Background(), testProfile)
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
tests := []struct {
name string
userID int64
uuid string
clientToken string
wantErr bool
}{
{
name: "正常创建Token指定UUID",
userID: 1,
uuid: "test-profile-uuid",
clientToken: "client-token-1",
wantErr: false,
},
{
name: "正常创建Token空clientToken",
userID: 1,
uuid: "test-profile-uuid",
clientToken: "",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
_, _, accessToken, clientToken, err := tokenService.Create(ctx, tt.userID, tt.uuid, tt.clientToken)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if accessToken == "" {
t.Error("accessToken不应为空")
}
if clientToken == "" {
t.Error("clientToken不应为空")
}
}
})
}
}
// TestTokenServiceImpl_Validate 测试验证Token
func TestTokenServiceImpl_Validate(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
// 预置Token
testToken := &model.Token{
AccessToken: "valid-access-token",
ClientToken: "valid-client-token",
UserID: 1,
ProfileId: "test-profile-uuid",
Usable: true,
}
_ = tokenRepo.Create(context.Background(), testToken)
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
tests := []struct {
name string
accessToken string
clientToken string
wantValid bool
}{
{
name: "有效Token完全匹配",
accessToken: "valid-access-token",
clientToken: "valid-client-token",
wantValid: true,
},
{
name: "有效Token只检查accessToken",
accessToken: "valid-access-token",
clientToken: "",
wantValid: true,
},
{
name: "无效TokenaccessToken不存在",
accessToken: "invalid-access-token",
clientToken: "",
wantValid: false,
},
{
name: "无效TokenclientToken不匹配",
accessToken: "valid-access-token",
clientToken: "wrong-client-token",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
isValid := tokenService.Validate(ctx, tt.accessToken, tt.clientToken)
if isValid != tt.wantValid {
t.Errorf("Token验证结果不匹配: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestTokenServiceImpl_Invalidate 测试注销Token
func TestTokenServiceImpl_Invalidate(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
// 预置Token
testToken := &model.Token{
AccessToken: "token-to-invalidate",
ClientToken: "client-token",
UserID: 1,
ProfileId: "test-profile-uuid",
Usable: true,
}
_ = tokenRepo.Create(context.Background(), testToken)
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
ctx := context.Background()
// 验证Token存在
isValid := tokenService.Validate(ctx, "token-to-invalidate", "")
if !isValid {
t.Error("Token应该有效")
}
// 注销Token
tokenService.Invalidate(ctx, "token-to-invalidate")
// 验证Token已失效从repo中删除
_, err := tokenRepo.FindByAccessToken(context.Background(), "token-to-invalidate")
if err == nil {
t.Error("Token应该已被删除")
}
}
// TestTokenServiceImpl_InvalidateUserTokens 测试注销用户所有Token
func TestTokenServiceImpl_InvalidateUserTokens(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
// 预置多个Token
for i := 1; i <= 3; i++ {
_ = tokenRepo.Create(context.Background(), &model.Token{
AccessToken: fmt.Sprintf("user1-token-%d", i),
ClientToken: "client-token",
UserID: 1,
ProfileId: "test-profile-uuid",
Usable: true,
})
}
_ = tokenRepo.Create(context.Background(), &model.Token{
AccessToken: "user2-token-1",
ClientToken: "client-token",
UserID: 2,
ProfileId: "test-profile-uuid-2",
Usable: true,
})
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
ctx := context.Background()
// 注销用户1的所有Token
tokenService.InvalidateUserTokens(ctx, 1)
// 验证用户1的Token已失效
tokens, _ := tokenRepo.GetByUserID(context.Background(), 1)
if len(tokens) > 0 {
t.Errorf("用户1的Token应该全部被删除但还剩 %d 个", len(tokens))
}
// 验证用户2的Token仍然存在
tokens2, _ := tokenRepo.GetByUserID(context.Background(), 2)
if len(tokens2) != 1 {
t.Errorf("用户2的Token应该仍然存在期望1个实际 %d 个", len(tokens2))
}
}
// TestTokenServiceImpl_Refresh 覆盖 Refresh 的主要分支
func TestTokenServiceImpl_Refresh(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
// 预置 Profile 与 Token
profile := &model.Profile{
UUID: "profile-uuid",
UserID: 1,
}
_ = profileRepo.Create(context.Background(), profile)
oldToken := &model.Token{
AccessToken: "old-token",
ClientToken: "client-token",
UserID: 1,
ProfileId: "",
Usable: true,
}
_ = tokenRepo.Create(context.Background(), oldToken)
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
ctx := context.Background()
// 正常刷新,不指定 profile
newAccess, client, err := tokenService.Refresh(ctx, "old-token", "client-token", "")
if err != nil {
t.Fatalf("Refresh 正常情况失败: %v", err)
}
if newAccess == "" || client != "client-token" {
t.Fatalf("Refresh 返回值异常: access=%s, client=%s", newAccess, client)
}
// accessToken 为空
if _, _, err := tokenService.Refresh(ctx, "", "client-token", ""); err == nil {
t.Fatalf("Refresh 在 accessToken 为空时应返回错误")
}
}
// TestTokenServiceImpl_GetByAccessToken 封装 GetUUIDByAccessToken / GetUserIDByAccessToken
func TestTokenServiceImpl_GetByAccessToken(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
token := &model.Token{
AccessToken: "token-1",
UserID: 42,
ProfileId: "profile-42",
Usable: true,
}
_ = tokenRepo.Create(context.Background(), token)
tokenService := NewTokenService(tokenRepo, profileRepo, logger)
ctx := context.Background()
uuid, err := tokenService.GetUUIDByAccessToken(ctx, "token-1")
if err != nil || uuid != "profile-42" {
t.Fatalf("GetUUIDByAccessToken 返回错误: uuid=%s, err=%v", uuid, err)
}
uid, err := tokenService.GetUserIDByAccessToken(ctx, "token-1")
if err != nil || uid != 42 {
t.Fatalf("GetUserIDByAccessToken 返回错误: uid=%d, err=%v", uid, err)
}
}
// TestTokenServiceImpl_validateProfileByUserID 直接测试内部校验逻辑
func TestTokenServiceImpl_validateProfileByUserID(t *testing.T) {
tokenRepo := NewMockTokenRepository()
profileRepo := NewMockProfileRepository()
logger := zap.NewNop()
svc := &tokenService{
tokenRepo: tokenRepo,
profileRepo: profileRepo,
logger: logger,
}
// 预置 Profile
profile := &model.Profile{
UUID: "p-1",
UserID: 1,
}
_ = profileRepo.Create(context.Background(), profile)
// 参数非法
if ok, err := svc.validateProfileByUserID(context.Background(), 0, ""); err == nil || ok {
t.Fatalf("validateProfileByUserID 在参数非法时应返回错误")
}
// Profile 不存在
if ok, err := svc.validateProfileByUserID(context.Background(), 1, "not-exists"); err == nil || ok {
t.Fatalf("validateProfileByUserID 在 Profile 不存在时应返回错误")
}
// 用户与 Profile 匹配
if ok, err := svc.validateProfileByUserID(context.Background(), 1, "p-1"); err != nil || !ok {
t.Fatalf("validateProfileByUserID 匹配时应返回 true, err=%v", err)
}
// 用户与 Profile 不匹配
if ok, err := svc.validateProfileByUserID(context.Background(), 2, "p-1"); err != nil || ok {
t.Fatalf("validateProfileByUserID 不匹配时应返回 false, err=%v", err)
}
}

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) {
// 初始化Service // 初始化Service
// 注意redisClient 和 cacheManager 传入 nil因为 Register 方法中没有使用它们 // 注意redisClient 和 cacheManager 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -130,7 +130,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser) _ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -211,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -259,7 +259,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -294,7 +294,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -322,7 +322,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
_ = userRepo.Create(context.Background(), user2) _ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
logger := zap.NewNop() logger := zap.NewNop()
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
// 未配置时走默认值 // 未配置时走默认值
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger)
if got := userService.GetMaxProfilesPerUser(); got != 5 { if got := userService.GetMaxProfilesPerUser(); got != 5 {
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got) t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
} }

View File

@@ -22,7 +22,7 @@ type yggdrasilServiceComposite struct {
serializationService SerializationService serializationService SerializationService
certificateService CertificateService certificateService CertificateService
profileRepo repository.ProfileRepository profileRepo repository.ProfileRepository
tokenService TokenService // 使用TokenService接口不直接依赖TokenRepository tokenRepo repository.TokenRepository
logger *zap.Logger logger *zap.Logger
} }
@@ -31,11 +31,11 @@ func NewYggdrasilServiceComposite(
db *gorm.DB, db *gorm.DB,
userRepo repository.UserRepository, userRepo repository.UserRepository,
profileRepo repository.ProfileRepository, profileRepo repository.ProfileRepository,
tokenRepo repository.TokenRepository,
yggdrasilRepo repository.YggdrasilRepository, yggdrasilRepo repository.YggdrasilRepository,
signatureService *SignatureService, signatureService *SignatureService,
redisClient *redis.Client, redisClient *redis.Client,
logger *zap.Logger, logger *zap.Logger,
tokenService TokenService, // 新增TokenService接口
) YggdrasilService { ) YggdrasilService {
// 创建各个专门的服务 // 创建各个专门的服务
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger) authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger)
@@ -53,7 +53,7 @@ func NewYggdrasilServiceComposite(
serializationService: serializationService, serializationService: serializationService,
certificateService: certificateService, certificateService: certificateService,
profileRepo: profileRepo, profileRepo: profileRepo,
tokenService: tokenService, tokenRepo: tokenRepo,
logger: logger, logger: logger,
} }
} }
@@ -75,8 +75,8 @@ func (s *yggdrasilServiceComposite) ResetYggdrasilPassword(ctx context.Context,
// JoinServer 加入服务器 // JoinServer 加入服务器
func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error { func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error {
// 通过TokenService验证Token并获取UUID // 验证Token
uuid, err := s.tokenService.GetUUIDByAccessToken(ctx, accessToken) token, err := s.tokenRepo.FindByAccessToken(ctx, accessToken)
if err != nil { if err != nil {
s.logger.Error("验证Token失败", s.logger.Error("验证Token失败",
zap.Error(err), zap.Error(err),
@@ -87,7 +87,7 @@ func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, ac
// 格式化UUID并验证与Token关联的配置文件 // 格式化UUID并验证与Token关联的配置文件
formattedProfile := utils.FormatUUID(selectedProfile) formattedProfile := utils.FormatUUID(selectedProfile)
if uuid != formattedProfile { if token.ProfileId != formattedProfile {
return errors.New("selectedProfile与Token不匹配") return errors.New("selectedProfile与Token不匹配")
} }

View File

@@ -1,168 +0,0 @@
package task
import (
"context"
"math/rand"
"runtime/debug"
"sync"
"time"
"go.uber.org/zap"
)
// Task 定义可调度任务
type Task interface {
Name() string
Interval() time.Duration
Run(ctx context.Context) error
}
// Runner 简单的周期任务调度器
type Runner struct {
tasks []Task
logger *zap.Logger
wg sync.WaitGroup
startImmediately bool
jitterPercent float64
}
// NewRunner 创建任务调度器
func NewRunner(logger *zap.Logger, tasks ...Task) *Runner {
return NewRunnerWithOptions(logger, tasks)
}
// RunnerOption 运行器配置项
type RunnerOption func(r *Runner)
// WithStartImmediately 是否启动后立即执行一次(默认 true
func WithStartImmediately(start bool) RunnerOption {
return func(r *Runner) {
r.startImmediately = start
}
}
// WithJitter 为执行间隔增加 0~percent 之间的随机抖动percent=0 关闭默认0
// 可降低多个任务同时触发的概率
func WithJitter(percent float64) RunnerOption {
return func(r *Runner) {
if percent < 0 {
percent = 0
}
r.jitterPercent = percent
}
}
// NewRunnerWithOptions 支持可选配置的创建函数
func NewRunnerWithOptions(logger *zap.Logger, tasks []Task, opts ...RunnerOption) *Runner {
r := &Runner{
tasks: tasks,
logger: logger,
startImmediately: true,
jitterPercent: 0,
}
for _, opt := range opts {
opt(r)
}
return r
}
// Start 启动所有任务(异步)
func (r *Runner) Start(ctx context.Context) {
for _, t := range r.tasks {
task := t
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer r.recoverPanic(task)
interval := r.normalizeInterval(task.Interval())
// 可选:立即执行一次
if r.startImmediately {
r.runOnce(ctx, task)
}
// 周期执行
for {
wait := r.applyJitter(interval)
if !r.wait(ctx, wait) {
return
}
// 每轮读取最新的 interval允许任务动态调整间隔
interval = r.normalizeInterval(task.Interval())
select {
case <-ctx.Done():
return
default:
r.runOnce(ctx, task)
}
}
}()
}
}
// Wait 等待所有任务退出
func (r *Runner) Wait() {
r.wg.Wait()
}
func (r *Runner) runOnce(ctx context.Context, task Task) {
if err := task.Run(ctx); err != nil && r.logger != nil {
r.logger.Warn("任务执行失败", zap.String("task", task.Name()), zap.Error(err))
}
}
// normalizeInterval 确保间隔为正值
func (r *Runner) normalizeInterval(d time.Duration) time.Duration {
if d <= 0 {
return time.Minute
}
return d
}
// applyJitter 在基础间隔上添加最多 jitterPercent 的随机抖动
func (r *Runner) applyJitter(base time.Duration) time.Duration {
if r.jitterPercent <= 0 {
return base
}
maxJitter := time.Duration(float64(base) * r.jitterPercent)
if maxJitter <= 0 {
return base
}
return base + time.Duration(rand.Int63n(int64(maxJitter)))
}
// wait 封装带 context 的 sleep
func (r *Runner) wait(ctx context.Context, d time.Duration) bool {
if d <= 0 {
select {
case <-ctx.Done():
return false
default:
return true
}
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// recoverPanic 防止任务 panic 导致 goroutine 退出
func (r *Runner) recoverPanic(task Task) {
if rec := recover(); rec != nil && r.logger != nil {
r.logger.Error("任务发生panic",
zap.String("task", task.Name()),
zap.Any("panic", rec),
zap.ByteString("stack", debug.Stack()),
)
}
}

View File

@@ -1,65 +0,0 @@
package task
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
type mockTask struct {
name string
interval time.Duration
err error
runCount *atomic.Int32
}
func (m *mockTask) Name() string { return m.name }
func (m *mockTask) Interval() time.Duration { return m.interval }
func (m *mockTask) Run(ctx context.Context) error {
if m.runCount != nil {
m.runCount.Add(1)
}
return m.err
}
func TestRunner_StartAndWait(t *testing.T) {
runCount := &atomic.Int32{}
task := &mockTask{name: "ok", interval: 20 * time.Millisecond, runCount: runCount}
runner := NewRunner(zap.NewNop(), task)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
time.Sleep(60 * time.Millisecond)
cancel()
runner.Wait()
if runCount.Load() == 0 {
t.Fatalf("expected task to run at least once")
}
}
func TestRunner_RunErrorLogged(t *testing.T) {
runCount := &atomic.Int32{}
task := &mockTask{name: "err", interval: 10 * time.Millisecond, err: errors.New("boom"), runCount: runCount}
runner := NewRunner(zap.NewNop(), task)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
time.Sleep(25 * time.Millisecond)
cancel()
runner.Wait()
if runCount.Load() == 0 {
t.Fatalf("expected task to be attempted")
}
}

View File

@@ -1,56 +0,0 @@
package testutil
import (
"testing"
"time"
"carrotskin/internal/model"
"carrotskin/pkg/database"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// NewTestDB 返回基于内存的 sqlite 数据库并完成模型迁移
func NewTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite memory db: %v", err)
}
if err := db.AutoMigrate(
&model.User{},
&model.UserPointLog{},
&model.UserLoginLog{},
&model.Profile{},
&model.Texture{},
&model.UserTextureFavorite{},
&model.TextureDownloadLog{},
&model.Client{},
&model.Yggdrasil{},
&model.SystemConfig{},
&model.AuditLog{},
&model.CasbinRule{},
); err != nil {
t.Fatalf("failed to migrate models: %v", err)
}
return db
}
// NewNoopLogger 返回无输出 logger
func NewNoopLogger() *zap.Logger {
return zap.NewNop()
}
// NewTestCache 返回禁用 redis 的缓存管理器(用于单元测试)
func NewTestCache() *database.CacheManager {
return database.NewCacheManager(nil, database.CacheConfig{
Prefix: "test:",
Expiration: 1 * time.Minute,
Enabled: false,
})
}

View File

@@ -1,27 +0,0 @@
package testutil
import "testing"
func TestNewTestDB(t *testing.T) {
db := NewTestDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("DB() err: %v", err)
}
if err := sqlDB.Ping(); err != nil {
t.Fatalf("ping err: %v", err)
}
}
func TestNewTestCache(t *testing.T) {
cache := NewTestCache()
if cache.Policy.UserTTL == 0 {
t.Fatalf("expected defaults filled")
}
// disabled cache should not error on Set
if err := cache.Set(nil, "k", "v"); err != nil {
t.Fatalf("Set on disabled cache should be nil err, got %v", err)
}
}

View File

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

View File

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

View File

@@ -1,320 +0,0 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"time"
"carrotskin/pkg/redis"
"go.uber.org/zap"
)
// TokenMetadata Token元数据存储在Redis中
type TokenMetadata struct {
UserID int64 `json:"user_id"`
ProfileID string `json:"profile_id"`
ClientUUID string `json:"client_uuid"`
ClientToken string `json:"client_token"`
Version int `json:"version"`
CreatedAt int64 `json:"created_at"`
}
// TokenStoreRedis Redis Token存储实现
type TokenStoreRedis struct {
redis *redis.Client
logger *zap.Logger
keyPrefix string
defaultTTL time.Duration
staleTTL time.Duration
maxTokensPerUser int
}
// NewTokenStoreRedis 创建Redis Token存储
func NewTokenStoreRedis(
redisClient *redis.Client,
logger *zap.Logger,
opts ...TokenStoreOption,
) *TokenStoreRedis {
options := &tokenStoreOptions{
keyPrefix: "token:",
defaultTTL: 24 * time.Hour,
staleTTL: 30 * 24 * time.Hour,
maxTokensPerUser: 10,
}
for _, opt := range opts {
opt(options)
}
return &TokenStoreRedis{
redis: redisClient,
logger: logger,
keyPrefix: options.keyPrefix,
defaultTTL: options.defaultTTL,
staleTTL: options.staleTTL,
maxTokensPerUser: options.maxTokensPerUser,
}
}
// tokenStoreOptions Token存储配置选项
type tokenStoreOptions struct {
keyPrefix string
defaultTTL time.Duration
staleTTL time.Duration
maxTokensPerUser int
}
// TokenStoreOption Token存储配置选项函数
type TokenStoreOption func(*tokenStoreOptions)
// WithKeyPrefix 设置Key前缀
func WithKeyPrefix(prefix string) TokenStoreOption {
return func(o *tokenStoreOptions) {
o.keyPrefix = prefix
}
}
// WithDefaultTTL 设置默认TTL
func WithDefaultTTL(ttl time.Duration) TokenStoreOption {
return func(o *tokenStoreOptions) {
o.defaultTTL = ttl
}
}
// WithStaleTTL 设置过期但可用时间
func WithStaleTTL(ttl time.Duration) TokenStoreOption {
return func(o *tokenStoreOptions) {
o.staleTTL = ttl
}
}
// WithMaxTokensPerUser 设置每个用户的最大Token数
func WithMaxTokensPerUser(max int) TokenStoreOption {
return func(o *tokenStoreOptions) {
o.maxTokensPerUser = max
}
}
// Store 存储Token
func (s *TokenStoreRedis) Store(ctx context.Context, accessToken string, metadata *TokenMetadata, ttl time.Duration) error {
if ttl <= 0 {
ttl = s.defaultTTL
}
// 序列化元数据
data, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("序列化Token元数据失败: %w", err)
}
// 存储Token
tokenKey := s.getTokenKey(accessToken)
if err := s.redis.Set(ctx, tokenKey, data, ttl); err != nil {
return fmt.Errorf("存储Token失败: %w", err)
}
// 添加到用户Token集合
userTokensKey := s.getUserTokensKey(metadata.UserID)
if err := s.redis.SAdd(ctx, userTokensKey, accessToken); err != nil {
return fmt.Errorf("添加到用户Token集合失败: %w", err)
}
// 清理过期Token后台执行
go s.cleanupUserTokens(context.Background(), metadata.UserID)
s.logger.Debug("Token已存储",
zap.String("token", accessToken[:20]+"..."),
zap.Int64("userId", metadata.UserID),
zap.Duration("ttl", ttl),
)
return nil
}
// Retrieve 获取Token元数据
func (s *TokenStoreRedis) Retrieve(ctx context.Context, accessToken string) (*TokenMetadata, error) {
tokenKey := s.getTokenKey(accessToken)
data, err := s.redis.Get(ctx, tokenKey)
if err != nil {
return nil, fmt.Errorf("获取Token失败: %w", err)
}
var metadata TokenMetadata
if err := json.Unmarshal([]byte(data), &metadata); err != nil {
return nil, fmt.Errorf("解析Token元数据失败: %w", err)
}
return &metadata, nil
}
// Delete 删除Token
func (s *TokenStoreRedis) Delete(ctx context.Context, accessToken string) error {
tokenKey := s.getTokenKey(accessToken)
// 先获取Token元数据以获取UserID
metadata, err := s.Retrieve(ctx, accessToken)
if err != nil {
// Token可能已过期忽略错误
return nil
}
// 删除Token
if err := s.redis.Del(ctx, tokenKey); err != nil {
return fmt.Errorf("删除Token失败: %w", err)
}
// 从用户Token集合中移除
userTokensKey := s.getUserTokensKey(metadata.UserID)
if err := s.redis.SRem(ctx, userTokensKey, accessToken); err != nil {
return fmt.Errorf("从用户Token集合移除失败: %w", err)
}
s.logger.Debug("Token已删除",
zap.String("token", accessToken[:20]+"..."),
zap.Int64("userId", metadata.UserID),
)
return nil
}
// DeleteByUserID 删除用户的所有Token
func (s *TokenStoreRedis) DeleteByUserID(ctx context.Context, userID int64) error {
userTokensKey := s.getUserTokensKey(userID)
// 获取用户所有Token
tokens, err := s.redis.SMembers(ctx, userTokensKey)
if err != nil {
return fmt.Errorf("获取用户Token列表失败: %w", err)
}
// 删除所有Token
if len(tokens) > 0 {
tokenKeys := make([]string, len(tokens))
for i, token := range tokens {
tokenKeys[i] = s.getTokenKey(token)
}
if err := s.redis.Del(ctx, tokenKeys...); err != nil {
return fmt.Errorf("批量删除Token失败: %w", err)
}
}
// 删除用户Token集合
if err := s.redis.Del(ctx, userTokensKey); err != nil {
return fmt.Errorf("删除用户Token集合失败: %w", err)
}
s.logger.Info("用户所有Token已删除",
zap.Int64("userId", userID),
zap.Int("count", len(tokens)),
)
return nil
}
// Exists 检查Token是否存在
func (s *TokenStoreRedis) Exists(ctx context.Context, accessToken string) (bool, error) {
tokenKey := s.getTokenKey(accessToken)
count, err := s.redis.Exists(ctx, tokenKey)
if err != nil {
return false, fmt.Errorf("检查Token存在失败: %w", err)
}
return count > 0, nil
}
// GetTTL 获取Token的剩余TTL
func (s *TokenStoreRedis) GetTTL(ctx context.Context, accessToken string) (time.Duration, error) {
tokenKey := s.getTokenKey(accessToken)
return s.redis.TTL(ctx, tokenKey)
}
// RefreshTTL 刷新Token的TTL
func (s *TokenStoreRedis) RefreshTTL(ctx context.Context, accessToken string, ttl time.Duration) error {
if ttl <= 0 {
ttl = s.defaultTTL
}
tokenKey := s.getTokenKey(accessToken)
if err := s.redis.Expire(ctx, tokenKey, ttl); err != nil {
return fmt.Errorf("刷新Token TTL失败: %w", err)
}
return nil
}
// GetCountByUser 获取用户的Token数量
func (s *TokenStoreRedis) GetCountByUser(ctx context.Context, userID int64) (int64, error) {
userTokensKey := s.getUserTokensKey(userID)
count, err := s.redis.SMembers(ctx, userTokensKey)
if err != nil {
return 0, fmt.Errorf("获取用户Token数量失败: %w", err)
}
return int64(len(count)), nil
}
// cleanupUserTokens 清理用户的过期Token保留最新的N个
func (s *TokenStoreRedis) cleanupUserTokens(ctx context.Context, userID int64) {
userTokensKey := s.getUserTokensKey(userID)
// 获取用户所有Token
tokens, err := s.redis.SMembers(ctx, userTokensKey)
if err != nil {
s.logger.Error("获取用户Token列表失败", zap.Error(err), zap.Int64("userId", userID))
return
}
// 清理过期的Token验证它们是否仍存在
validTokens := make([]string, 0, len(tokens))
for _, token := range tokens {
tokenKey := s.getTokenKey(token)
exists, err := s.redis.Exists(ctx, tokenKey)
if err != nil {
s.logger.Error("检查Token存在失败", zap.Error(err), zap.String("token", token[:20]+"..."))
continue
}
if exists > 0 {
validTokens = append(validTokens, token)
}
}
// 如果没有变化,直接返回
if len(validTokens) == len(tokens) {
return
}
// 更新用户Token集合
if len(validTokens) == 0 {
s.redis.Del(ctx, userTokensKey)
} else {
// 重新设置集合
s.redis.Del(ctx, userTokensKey)
for _, token := range validTokens {
s.redis.SAdd(ctx, userTokensKey, token)
}
}
// 如果超过限制删除最旧的Token这里简化处理可以根据createdAt排序
if len(validTokens) > s.maxTokensPerUser {
tokensToDelete := validTokens[s.maxTokensPerUser:]
for _, token := range tokensToDelete {
s.Delete(ctx, token)
}
s.logger.Info("清理用户多余Token",
zap.Int64("userId", userID),
zap.Int("deleted", len(tokensToDelete)),
)
}
}
// getTokenKey 生成Token的Redis Key
func (s *TokenStoreRedis) getTokenKey(accessToken string) string {
return s.keyPrefix + accessToken
}
// getUserTokensKey 生成用户Token集合的Redis Key
func (s *TokenStoreRedis) getUserTokensKey(userID int64) string {
return fmt.Sprintf("user:%d:tokens", userID)
}

View File

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

View File

@@ -1,47 +0,0 @@
package config
import (
"os"
"testing"
"github.com/spf13/viper"
)
// 重置 viper避免测试间干扰
func resetViper() {
viper.Reset()
}
func TestLoad_DefaultsAndBucketsOverride(t *testing.T) {
resetViper()
// 设置部分环境变量覆盖
_ = os.Setenv("RUSTFS_BUCKET_TEXTURES", "tex-bkt")
_ = os.Setenv("RUSTFS_BUCKET_AVATARS", "ava-bkt")
_ = os.Setenv("DATABASE_MAX_IDLE_CONNS", "20")
_ = os.Setenv("DATABASE_MAX_OPEN_CONNS", "50")
_ = os.Setenv("DATABASE_CONN_MAX_LIFETIME", "2h")
_ = os.Setenv("DATABASE_CONN_MAX_IDLE_TIME", "30m")
cfg, err := Load()
if err != nil {
t.Fatalf("Load err: %v", err)
}
// 默认值检查
if cfg.Server.Port == "" || cfg.Database.Driver == "" || cfg.Redis.Host == "" {
t.Fatalf("expected defaults filled: %+v", cfg)
}
// 覆盖检查
if cfg.RustFS.Buckets["textures"] != "tex-bkt" || cfg.RustFS.Buckets["avatars"] != "ava-bkt" {
t.Fatalf("buckets override failed: %+v", cfg.RustFS.Buckets)
}
if cfg.Database.MaxIdleConns != 20 || cfg.Database.MaxOpenConns != 50 {
t.Fatalf("db pool override failed: %+v", cfg.Database)
}
if cfg.Database.ConnMaxLifetime.String() != "2h0m0s" || cfg.Database.ConnMaxIdleTime.String() != "30m0s" {
t.Fatalf("db duration override failed: %v %v", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime)
}
}

View File

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

View File

@@ -14,24 +14,12 @@ type CacheConfig struct {
Prefix string // 缓存键前缀 Prefix string // 缓存键前缀
Expiration time.Duration // 过期时间 Expiration time.Duration // 过期时间
Enabled bool // 是否启用缓存 Enabled bool // 是否启用缓存
Policy CachePolicy // 缓存策略(可选,不配置则回落到 Expiration
}
// CachePolicy 缓存策略,用于为不同实体设置默认 TTL
type CachePolicy struct {
UserTTL time.Duration
UserEmailTTL time.Duration
ProfileTTL time.Duration
ProfileListTTL time.Duration
TextureTTL time.Duration
TextureListTTL time.Duration
} }
// CacheManager 缓存管理器 // CacheManager 缓存管理器
type CacheManager struct { type CacheManager struct {
redis *redis.Client redis *redis.Client
config CacheConfig config CacheConfig
Policy CachePolicy
} }
// NewCacheManager 创建缓存管理器 // NewCacheManager 创建缓存管理器
@@ -43,33 +31,9 @@ func NewCacheManager(redisClient *redis.Client, config CacheConfig) *CacheManage
config.Expiration = 5 * time.Minute config.Expiration = 5 * time.Minute
} }
// 填充默认策略(未配置时退回全局过期时间)
applyPolicyDefaults := func(p *CachePolicy) {
if p.UserTTL == 0 {
p.UserTTL = config.Expiration
}
if p.UserEmailTTL == 0 {
p.UserEmailTTL = config.Expiration
}
if p.ProfileTTL == 0 {
p.ProfileTTL = config.Expiration
}
if p.ProfileListTTL == 0 {
p.ProfileListTTL = config.Expiration
}
if p.TextureTTL == 0 {
p.TextureTTL = config.Expiration
}
if p.TextureListTTL == 0 {
p.TextureListTTL = config.Expiration
}
}
applyPolicyDefaults(&config.Policy)
return &CacheManager{ return &CacheManager{
redis: redisClient, redis: redisClient,
config: config, config: config,
Policy: config.Policy,
} }
} }
@@ -92,14 +56,6 @@ func (cm *CacheManager) Get(ctx context.Context, key string, dest interface{}) e
return json.Unmarshal(data, dest) return json.Unmarshal(data, dest)
} }
// TryGet 获取缓存,命中时返回 true不视为错误
func (cm *CacheManager) TryGet(ctx context.Context, key string, dest interface{}) (bool, error) {
if err := cm.Get(ctx, key, dest); err != nil {
return false, err
}
return true, nil
}
// Set 设置缓存 // Set 设置缓存
func (cm *CacheManager) Set(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error { func (cm *CacheManager) Set(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error {
if !cm.config.Enabled || cm.redis == nil { if !cm.config.Enabled || cm.redis == nil {
@@ -119,13 +75,6 @@ func (cm *CacheManager) Set(ctx context.Context, key string, value interface{},
return cm.redis.Set(ctx, cm.buildKey(key), data, exp) return cm.redis.Set(ctx, cm.buildKey(key), data, exp)
} }
// SetAsync 异步设置缓存,避免在主请求链路阻塞
func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) {
go func() {
_ = cm.Set(ctx, key, value, expiration...)
}()
}
// Delete 删除缓存 // Delete 删除缓存
func (cm *CacheManager) Delete(ctx context.Context, keys ...string) error { func (cm *CacheManager) Delete(ctx context.Context, keys ...string) error {
if !cm.config.Enabled || cm.redis == nil { if !cm.config.Enabled || cm.redis == nil {
@@ -238,7 +187,11 @@ func Cached[T any](
} }
// 设置缓存(异步,不阻塞) // 设置缓存(异步,不阻塞)
cache.SetAsync(context.Background(), key, data, expiration...) go func() {
cacheCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = cache.Set(cacheCtx, key, data, expiration...)
}()
return data, nil return data, nil
} }
@@ -264,7 +217,11 @@ func CachedList[T any](
} }
// 设置缓存(异步,不阻塞) // 设置缓存(异步,不阻塞)
cache.SetAsync(context.Background(), key, data, expiration...) go func() {
cacheCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = cache.Set(cacheCtx, key, data, expiration...)
}()
return data, nil return data, nil
} }
@@ -349,9 +306,9 @@ func (b *CacheKeyBuilder) TextureList(userID int64, page int) string {
return fmt.Sprintf("%stexture:user:%d:page:%d", b.prefix, userID, page) return fmt.Sprintf("%stexture:user:%d:page:%d", b.prefix, userID, page)
} }
// TextureListPattern 构建材质列表缓存键模式(用于批量失效) // TextureRender 构建材质渲染缓存键
func (b *CacheKeyBuilder) TextureListPattern(userID int64) string { func (b *CacheKeyBuilder) TextureRender(textureID int64, renderType string, size int) string {
return fmt.Sprintf("%stexture:user:%d:*", b.prefix, userID) return fmt.Sprintf("%stexture:render:%d:%s:%d", b.prefix, textureID, renderType, size)
} }
// Token 构建令牌缓存键 // Token 构建令牌缓存键

View File

@@ -1,184 +0,0 @@
package database
import (
"context"
"testing"
"time"
pkgRedis "carrotskin/pkg/redis"
miniredis "github.com/alicebob/miniredis/v2"
goRedis "github.com/redis/go-redis/v9"
)
func newCacheWithMiniRedis(t *testing.T) (*CacheManager, func()) {
t.Helper()
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
rdb := goRedis.NewClient(&goRedis.Options{
Addr: mr.Addr(),
})
client := &pkgRedis.Client{Client: rdb}
cache := NewCacheManager(client, CacheConfig{
Prefix: "t:",
Expiration: time.Minute,
Enabled: true,
Policy: CachePolicy{
UserTTL: 2 * time.Minute,
UserEmailTTL: 3 * time.Minute,
ProfileTTL: 2 * time.Minute,
ProfileListTTL: 90 * time.Second,
TextureTTL: 2 * time.Minute,
TextureListTTL: 45 * time.Second,
},
})
cleanup := func() {
_ = rdb.Close()
mr.Close()
}
return cache, cleanup
}
func TestCacheManager_GetSet_TryGet(t *testing.T) {
cache, cleanup := newCacheWithMiniRedis(t)
defer cleanup()
ctx := context.Background()
type User struct {
ID int
Name string
}
u := User{ID: 1, Name: "alice"}
if err := cache.Set(ctx, "user:1", u, 10*time.Second); err != nil {
t.Fatalf("Set err: %v", err)
}
var got User
if err := cache.Get(ctx, "user:1", &got); err != nil {
t.Fatalf("Get err: %v", err)
}
if got != u {
t.Fatalf("unexpected value: %+v", got)
}
var got2 User
ok, err := cache.TryGet(ctx, "user:1", &got2)
if err != nil || !ok {
t.Fatalf("TryGet failed, ok=%v err=%v", ok, err)
}
if got2 != u {
t.Fatalf("unexpected TryGet: %+v", got2)
}
}
func TestCacheManager_DeletePattern(t *testing.T) {
cache, cleanup := newCacheWithMiniRedis(t)
defer cleanup()
ctx := context.Background()
_ = cache.Set(ctx, "user:1", "a", 0)
_ = cache.Set(ctx, "user:2", "b", 0)
_ = cache.Set(ctx, "profile:1", "c", 0)
// 删除 user:* 键
if err := cache.DeletePattern(ctx, "user:*"); err != nil {
t.Fatalf("DeletePattern err: %v", err)
}
var v string
ok, _ := cache.TryGet(ctx, "user:1", &v)
if ok {
t.Fatalf("expected user:1 deleted")
}
ok, _ = cache.TryGet(ctx, "user:2", &v)
if ok {
t.Fatalf("expected user:2 deleted")
}
ok, _ = cache.TryGet(ctx, "profile:1", &v)
if !ok {
t.Fatalf("expected profile:1 kept")
}
}
func TestCachedAndCachedList(t *testing.T) {
cache, cleanup := newCacheWithMiniRedis(t)
defer cleanup()
ctx := context.Background()
callCount := 0
result, err := Cached(ctx, cache, "key1", func() (*string, error) {
callCount++
val := "hello"
return &val, nil
}, cache.Policy.UserTTL)
if err != nil || *result != "hello" || callCount != 1 {
t.Fatalf("Cached first call failed")
}
// 等待缓存写入完成
for i := 0; i < 10; i++ {
var tmp string
if ok, _ := cache.TryGet(ctx, "key1", &tmp); ok {
break
}
time.Sleep(10 * time.Millisecond)
}
// 第二次应命中缓存
_, err = Cached(ctx, cache, "key1", func() (*string, error) {
callCount++
val := "world"
return &val, nil
}, cache.Policy.UserTTL)
if err != nil || callCount != 1 {
t.Fatalf("Cached should hit cache, callCount=%d err=%v", callCount, err)
}
listCall := 0
_, err = CachedList(ctx, cache, "list", func() ([]string, error) {
listCall++
return []string{"a", "b"}, nil
}, cache.Policy.ProfileListTTL)
if err != nil || listCall != 1 {
t.Fatalf("CachedList first call failed")
}
for i := 0; i < 10; i++ {
var tmp []string
if ok, _ := cache.TryGet(ctx, "list", &tmp); ok {
break
}
time.Sleep(10 * time.Millisecond)
}
_, err = CachedList(ctx, cache, "list", func() ([]string, error) {
listCall++
return []string{"c"}, nil
}, cache.Policy.ProfileListTTL)
if err != nil || listCall != 1 {
t.Fatalf("CachedList should hit cache, calls=%d err=%v", listCall, err)
}
}
func TestIncrementWithExpire(t *testing.T) {
cache, cleanup := newCacheWithMiniRedis(t)
defer cleanup()
ctx := context.Background()
val, err := cache.IncrementWithExpire(ctx, "counter", time.Second)
if err != nil || val != 1 {
t.Fatalf("first increment failed, val=%d err=%v", val, err)
}
val, err = cache.IncrementWithExpire(ctx, "counter", time.Second)
if err != nil || val != 2 {
t.Fatalf("second increment failed, val=%d err=%v", val, err)
}
ttl, err := cache.TTL(ctx, "counter")
if err != nil || ttl <= 0 {
t.Fatalf("TTL not set: ttl=%v err=%v", ttl, err)
}
}

View File

@@ -75,6 +75,7 @@ func AutoMigrate(logger *zap.Logger) error {
&model.TextureDownloadLog{}, &model.TextureDownloadLog{},
// 认证相关表 // 认证相关表
&model.Token{},
&model.Client{}, // Client表用于管理Token版本 &model.Client{}, // Client表用于管理Token版本
// Yggdrasil相关表在User之后创建因为它引用User // Yggdrasil相关表在User之后创建因为它引用User

View File

@@ -1,24 +0,0 @@
package database
import (
"testing"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// 使用内存 sqlite 验证 AutoMigrate 关键路径,无需真实 Postgres
func TestAutoMigrate_WithSQLite(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite err: %v", err)
}
dbInstance = db
defer func() { dbInstance = nil }()
logger := zaptest.NewLogger(t)
if err := AutoMigrate(logger); err != nil {
t.Fatalf("AutoMigrate sqlite err: %v", err)
}
}

View File

@@ -9,7 +9,6 @@ import (
// TestGetDB_NotInitialized 测试未初始化时获取数据库实例 // TestGetDB_NotInitialized 测试未初始化时获取数据库实例
func TestGetDB_NotInitialized(t *testing.T) { func TestGetDB_NotInitialized(t *testing.T) {
dbInstance = nil
_, err := GetDB() _, err := GetDB()
if err == nil { if err == nil {
t.Error("未初始化时应该返回错误") t.Error("未初始化时应该返回错误")
@@ -23,7 +22,6 @@ func TestGetDB_NotInitialized(t *testing.T) {
// TestMustGetDB_Panic 测试MustGetDB在未初始化时panic // TestMustGetDB_Panic 测试MustGetDB在未初始化时panic
func TestMustGetDB_Panic(t *testing.T) { func TestMustGetDB_Panic(t *testing.T) {
dbInstance = nil
defer func() { defer func() {
if r := recover(); r == nil { if r := recover(); r == nil {
t.Error("MustGetDB 应该在未初始化时panic") t.Error("MustGetDB 应该在未初始化时panic")
@@ -35,7 +33,6 @@ func TestMustGetDB_Panic(t *testing.T) {
// TestInit_Database 测试数据库初始化逻辑 // TestInit_Database 测试数据库初始化逻辑
func TestInit_Database(t *testing.T) { func TestInit_Database(t *testing.T) {
dbInstance = nil
cfg := config.DatabaseConfig{ cfg := config.DatabaseConfig{
Driver: "postgres", Driver: "postgres",
Host: "localhost", Host: "localhost",
@@ -56,7 +53,7 @@ func TestInit_Database(t *testing.T) {
// 注意:实际连接可能失败,这是可以接受的 // 注意:实际连接可能失败,这是可以接受的
err := Init(cfg, logger) err := Init(cfg, logger)
if err != nil { if err != nil {
t.Skipf("数据库未运行,跳过连接测试: %v", err) t.Logf("Init() 返回错误(可能正常,如果数据库未运行): %v", err)
} }
} }
@@ -85,3 +82,4 @@ func TestClose_NotInitialized(t *testing.T) {
t.Errorf("Close() 在未初始化时应该返回nil实际返回: %v", err) t.Errorf("Close() 在未初始化时应该返回nil实际返回: %v", err)
} }
} }

View File

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

View File

@@ -1,56 +0,0 @@
package email
import (
"strings"
"sync"
"testing"
"carrotskin/pkg/config"
"go.uber.org/zap"
)
func resetEmailOnce() {
serviceInstance = nil
once = sync.Once{}
}
func TestEmailManager_Disabled(t *testing.T) {
resetEmailOnce()
cfg := config.EmailConfig{Enabled: false}
if err := Init(cfg, zap.NewNop()); err != nil {
t.Fatalf("Init disabled err: %v", err)
}
svc := MustGetService()
if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil {
t.Fatalf("expected error when disabled")
}
}
func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
resetEmailOnce()
cfg := config.EmailConfig{
Enabled: true,
SMTPHost: "127.0.0.1",
SMTPPort: 1, // invalid/closed port to trigger error quickly
Username: "user",
Password: "pwd",
FromName: "name",
}
_ = Init(cfg, zap.NewNop())
svc := MustGetService()
if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil {
t.Fatalf("expected send error with invalid smtp")
}
}
func TestEmailManager_SubjectAndBody(t *testing.T) {
svc := &Service{cfg: config.EmailConfig{FromName: "name", Username: "user"}, logger: zap.NewNop()}
if subj := svc.getSubject("email_verification"); subj == "" {
t.Fatalf("subject empty")
}
body := svc.getBody("123456", "change_email")
if !strings.Contains(body, "123456") || !strings.Contains(body, "更换邮箱") {
t.Fatalf("body content mismatch")
}
}

View File

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

View File

@@ -2,20 +2,13 @@ package email
import ( import (
"carrotskin/pkg/config" "carrotskin/pkg/config"
"sync"
"testing" "testing"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
) )
func resetEmail() {
serviceInstance = nil
once = sync.Once{}
}
// TestGetService_NotInitialized 测试未初始化时获取邮件服务 // TestGetService_NotInitialized 测试未初始化时获取邮件服务
func TestGetService_NotInitialized(t *testing.T) { func TestGetService_NotInitialized(t *testing.T) {
resetEmail()
_, err := GetService() _, err := GetService()
if err == nil { if err == nil {
t.Error("未初始化时应该返回错误") t.Error("未初始化时应该返回错误")
@@ -29,7 +22,6 @@ func TestGetService_NotInitialized(t *testing.T) {
// TestMustGetService_Panic 测试MustGetService在未初始化时panic // TestMustGetService_Panic 测试MustGetService在未初始化时panic
func TestMustGetService_Panic(t *testing.T) { func TestMustGetService_Panic(t *testing.T) {
resetEmail()
defer func() { defer func() {
if r := recover(); r == nil { if r := recover(); r == nil {
t.Error("MustGetService 应该在未初始化时panic") t.Error("MustGetService 应该在未初始化时panic")
@@ -41,14 +33,13 @@ func TestMustGetService_Panic(t *testing.T) {
// TestInit_Email 测试邮件服务初始化 // TestInit_Email 测试邮件服务初始化
func TestInit_Email(t *testing.T) { func TestInit_Email(t *testing.T) {
resetEmail()
cfg := config.EmailConfig{ cfg := config.EmailConfig{
Enabled: false, Enabled: false,
SMTPHost: "smtp.example.com", SMTPHost: "smtp.example.com",
SMTPPort: 587, SMTPPort: 587,
Username: "user@example.com", Username: "user@example.com",
Password: "password", Password: "password",
FromName: "noreply@example.com", FromName: "noreply@example.com",
} }
logger := zaptest.NewLogger(t) logger := zaptest.NewLogger(t)
@@ -67,3 +58,4 @@ func TestInit_Email(t *testing.T) {
t.Error("GetService() 返回的服务不应为nil") t.Error("GetService() 返回的服务不应为nil")
} }
} }

View File

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

View File

@@ -3,11 +3,8 @@ package redis
import ( import (
"carrotskin/pkg/config" "carrotskin/pkg/config"
"fmt" "fmt"
"os"
"sync" "sync"
"github.com/alicebob/miniredis/v2"
redis9 "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -18,69 +15,19 @@ var (
once sync.Once once sync.Once
// initError 初始化错误 // initError 初始化错误
initError error initError error
// miniredisInstance 用于测试/开发环境
miniredisInstance *miniredis.Miniredis
) )
// Init 初始化Redis客户端线程安全只会执行一次 // Init 初始化Redis客户端线程安全只会执行一次
// 如果Redis连接失败且环境为测试/开发则回退到miniredis
func Init(cfg config.RedisConfig, logger *zap.Logger) error { func Init(cfg config.RedisConfig, logger *zap.Logger) error {
var err error
once.Do(func() { once.Do(func() {
// 尝试连接真实Redis clientInstance, initError = New(cfg, logger)
clientInstance, err = New(cfg, logger) if initError != nil {
if err != nil { return
logger.Warn("Redis连接失败尝试使用miniredis回退", zap.Error(err))
// 检查是否允许回退到miniredis仅开发/测试环境)
if allowFallbackToMiniRedis() {
clientInstance, err = initMiniRedis(logger)
if err != nil {
initError = fmt.Errorf("Redis和miniredis都初始化失败: %w", err)
logger.Error("miniredis初始化失败", zap.Error(initError))
return
}
logger.Info("已回退到miniredis用于开发/测试环境")
} else {
initError = fmt.Errorf("Redis连接失败且不允许回退: %w", err)
logger.Error("Redis连接失败", zap.Error(initError))
return
}
} }
}) })
return initError return initError
} }
// allowFallbackToMiniRedis 检查是否允许回退到miniredis
func allowFallbackToMiniRedis() bool {
// 检查环境变量
env := os.Getenv("ENVIRONMENT")
return env == "development" || env == "test" || env == "dev" ||
os.Getenv("USE_MINIREDIS") == "true"
}
// initMiniRedis 初始化miniredis用于开发/测试环境)
func initMiniRedis(logger *zap.Logger) (*Client, error) {
var err error
miniredisInstance, err = miniredis.Run()
if err != nil {
return nil, fmt.Errorf("启动miniredis失败: %w", err)
}
// 创建Redis客户端连接到miniredis
redisClient := redis9.NewClient(&redis9.Options{
Addr: miniredisInstance.Addr(),
})
client := &Client{
Client: redisClient,
logger: logger,
}
logger.Info("miniredis已启动", zap.String("addr", miniredisInstance.Addr()))
return client, nil
}
// GetClient 获取Redis客户端实例线程安全 // GetClient 获取Redis客户端实例线程安全
func GetClient() (*Client, error) { func GetClient() (*Client, error) {
if clientInstance == nil { if clientInstance == nil {
@@ -98,21 +45,13 @@ func MustGetClient() *Client {
return client return client
} }
// Close 关闭Redis连接包括miniredis如果使用了
func Close() error {
var err error
if miniredisInstance != nil {
miniredisInstance.Close()
miniredisInstance = nil
}
if clientInstance != nil {
err = clientInstance.Close()
clientInstance = nil
}
return err
}
// IsUsingMiniRedis 检查是否使用了miniredis
func IsUsingMiniRedis() bool {
return miniredisInstance != nil
}

View File

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

View File

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

View File

@@ -1,71 +0,0 @@
package storage
import (
"context"
"testing"
"time"
"carrotskin/pkg/config"
"github.com/minio/minio-go/v7"
)
// 使用 nil client 仅测试纯函数和错误分支
func TestStorage_GetBucketAndBuildURL(t *testing.T) {
s := &StorageClient{
client: (*minio.Client)(nil),
buckets: map[string]string{"textures": "tex-bkt"},
publicURL: "http://localhost:9000",
}
if b, err := s.GetBucket("textures"); err != nil || b != "tex-bkt" {
t.Fatalf("GetBucket mismatch: %v %s", err, b)
}
if _, err := s.GetBucket("missing"); err == nil {
t.Fatalf("expected error for missing bucket")
}
if url := s.BuildFileURL("tex-bkt", "obj"); url != "http://localhost:9000/tex-bkt/obj" {
t.Fatalf("BuildFileURL mismatch: %s", url)
}
}
func TestNewStorage_SkipConnectWhenNoCreds(t *testing.T) {
// 当 AccessKey/Secret 为空时跳过 ListBuckets 测试,避免真实依赖
cfg := config.RustFSConfig{
Endpoint: "127.0.0.1:9000",
Buckets: map[string]string{"avatars": "ava", "textures": "tex"},
UseSSL: false,
}
if _, err := NewStorage(cfg); err != nil {
t.Fatalf("NewStorage should not error when creds empty: %v", err)
}
}
func TestPresignedHelpers_WithNilClient(t *testing.T) {
s := &StorageClient{
client: (*minio.Client)(nil),
buckets: map[string]string{"textures": "tex-bkt"},
publicURL: "http://localhost:9000",
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 预期会panicnil client用recover捕获
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("GeneratePresignedURL expected panic with nil client")
}
}()
_, _ = s.GeneratePresignedURL(ctx, "tex-bkt", "obj", time.Minute)
}()
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("GeneratePresignedPostURL expected panic with nil client")
}
}()
_, _ = s.GeneratePresignedPostURL(ctx, "tex-bkt", "obj", 0, 10, time.Minute)
}()
}

42
run.bat
View File

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

36
run.sh
View File

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

View File

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

View File

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